diff --git a/CHANGELOG.md b/CHANGELOG.md index 41702adf..7c687d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Starting v0.1.0 for Microsoft.Azure.WebJobs.Extensions.OpenAI.AzureAISearch, it - Added dotnet-isolated support for Embeddings - Added support for multiple search providers. +- Added dotnet-isolated support for Kusto and Azure AI Search RAG scenarios. ### Breaking changes diff --git a/OpenAI-Extension.sln b/OpenAI-Extension.sln index c96c923d..ebd879b9 100644 --- a/OpenAI-Extension.sln +++ b/OpenAI-Extension.sln @@ -47,12 +47,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "assistant", "assistant", "{ samples\assistant\demo.http = samples\assistant\demo.http EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rag", "rag", "{4CD51D81-D334-4FBC-A82B-8E2408913B7D}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rag-kusto", "rag-kusto", "{4CD51D81-D334-4FBC-A82B-8E2408913B7D}" ProjectSection(SolutionItems) = preProject - samples\rag\csharp-inproc\demo.http = samples\rag\csharp-inproc\demo.http + samples\rag-kusto\csharp-ooproc\demo.http = samples\rag-kusto\csharp-ooproc\demo.http EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SemanticSearchEmbeddings", "samples\rag\csharp-inproc\SemanticSearchEmbeddings.csproj", "{BD2064F5-6F56-45D4-A2E9-5C276FDAF4B1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SemanticSearchEmbeddings", "samples\rag-kusto\csharp-ooproc\SemanticSearchEmbeddings.csproj", "{3E9B9A7F-A98D-4CDD-9528-6D3077CAA4DA}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AssistantSample", "samples\assistant\csharp-ooproc\AssistantSample\AssistantSample.csproj", "{8573FE76-9920-4A95-9D29-911FD6B0AB25}" EndProject @@ -67,7 +67,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rag-aisearch", "rag-aisearc EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Embeddings", "samples\embeddings\csharp-ooproc\Embeddings\Embeddings.csproj", "{C9CCD98A-B4E5-4E8E-9163-F86AF2DBB645}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SemanticAISearchEmbeddings", "samples\rag-aisearch\csharp-inproc\SemanticAISearchEmbeddings.csproj", "{21EE2832-FDEB-4D5A-BD72-61BDDF86DC2B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SemanticAISearchEmbeddings", "samples\rag-aisearch\csharp-ooproc\SemanticAISearchEmbeddings.csproj", "{21EE2832-FDEB-4D5A-BD72-61BDDF86DC2B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Functions.Worker.Extensions.OpenAI.Kusto", "src\Functions.Worker.Extensions.OpenAI.Kusto\Functions.Worker.Extensions.OpenAI.Kusto.csproj", "{F413D075-38DA-4357-AB60-E6E157018827}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Functions.Worker.Extensions.OpenAI.AzureAISearch", "src\Functions.Worker.Extensions.OpenAI.AzureAISearch\Functions.Worker.Extensions.OpenAI.AzureAISearch.csproj", "{CAB7034D-FBA9-4A7D-B50B-E9029DFD0DEF}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -95,10 +99,10 @@ Global {6701A54D-E00F-46E8-9344-B1547805459E}.Debug|Any CPU.Build.0 = Debug|Any CPU {6701A54D-E00F-46E8-9344-B1547805459E}.Release|Any CPU.ActiveCfg = Release|Any CPU {6701A54D-E00F-46E8-9344-B1547805459E}.Release|Any CPU.Build.0 = Release|Any CPU - {BD2064F5-6F56-45D4-A2E9-5C276FDAF4B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BD2064F5-6F56-45D4-A2E9-5C276FDAF4B1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BD2064F5-6F56-45D4-A2E9-5C276FDAF4B1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BD2064F5-6F56-45D4-A2E9-5C276FDAF4B1}.Release|Any CPU.Build.0 = Release|Any CPU + {3E9B9A7F-A98D-4CDD-9528-6D3077CAA4DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E9B9A7F-A98D-4CDD-9528-6D3077CAA4DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E9B9A7F-A98D-4CDD-9528-6D3077CAA4DA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E9B9A7F-A98D-4CDD-9528-6D3077CAA4DA}.Release|Any CPU.Build.0 = Release|Any CPU {8573FE76-9920-4A95-9D29-911FD6B0AB25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8573FE76-9920-4A95-9D29-911FD6B0AB25}.Debug|Any CPU.Build.0 = Debug|Any CPU {8573FE76-9920-4A95-9D29-911FD6B0AB25}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -119,6 +123,14 @@ Global {21EE2832-FDEB-4D5A-BD72-61BDDF86DC2B}.Debug|Any CPU.Build.0 = Debug|Any CPU {21EE2832-FDEB-4D5A-BD72-61BDDF86DC2B}.Release|Any CPU.ActiveCfg = Release|Any CPU {21EE2832-FDEB-4D5A-BD72-61BDDF86DC2B}.Release|Any CPU.Build.0 = Release|Any CPU + {F413D075-38DA-4357-AB60-E6E157018827}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F413D075-38DA-4357-AB60-E6E157018827}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F413D075-38DA-4357-AB60-E6E157018827}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F413D075-38DA-4357-AB60-E6E157018827}.Release|Any CPU.Build.0 = Release|Any CPU + {CAB7034D-FBA9-4A7D-B50B-E9029DFD0DEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAB7034D-FBA9-4A7D-B50B-E9029DFD0DEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAB7034D-FBA9-4A7D-B50B-E9029DFD0DEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAB7034D-FBA9-4A7D-B50B-E9029DFD0DEF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -134,13 +146,15 @@ Global {6701A54D-E00F-46E8-9344-B1547805459E} = {46888A9D-8058-499C-9F2F-07800FF686EB} {C5578BE5-AAC7-4616-9A35-41CA5903882A} = {865C9FB5-1043-47BE-B650-25A2B42030A3} {4CD51D81-D334-4FBC-A82B-8E2408913B7D} = {865C9FB5-1043-47BE-B650-25A2B42030A3} - {BD2064F5-6F56-45D4-A2E9-5C276FDAF4B1} = {4CD51D81-D334-4FBC-A82B-8E2408913B7D} + {3E9B9A7F-A98D-4CDD-9528-6D3077CAA4DA} = {4CD51D81-D334-4FBC-A82B-8E2408913B7D} {8573FE76-9920-4A95-9D29-911FD6B0AB25} = {C5578BE5-AAC7-4616-9A35-41CA5903882A} {32196912-898F-428E-94EF-4C2333367977} = {7A80B95C-AD57-4057-AED0-48629B75874F} {346BE57C-341F-4EF4-A81A-BAC9D2F5B938} = {7A80B95C-AD57-4057-AED0-48629B75874F} {3E836929-3691-4DD1-AB8D-F3E0C3A42B5F} = {865C9FB5-1043-47BE-B650-25A2B42030A3} {C9CCD98A-B4E5-4E8E-9163-F86AF2DBB645} = {4B55F637-B067-43B4-B6FC-65171654315C} {21EE2832-FDEB-4D5A-BD72-61BDDF86DC2B} = {3E836929-3691-4DD1-AB8D-F3E0C3A42B5F} + {F413D075-38DA-4357-AB60-E6E157018827} = {7A80B95C-AD57-4057-AED0-48629B75874F} + {CAB7034D-FBA9-4A7D-B50B-E9029DFD0DEF} = {7A80B95C-AD57-4057-AED0-48629B75874F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {05A7A679-3A53-45B5-AE93-4313655E127D} diff --git a/README.md b/README.md index 93e7bbff..307b5667 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker.Extensions.OpenA )
[![NuGet](https://img.shields.io/nuget/v/Microsoft.Azure.WebJobs.Extensions.OpenAI.Kusto.svg?label=microsoft.azure.webjobs.extensions.openai.kusto)](https://www.nuget.org/packages/Microsoft.Azure.WebJobs.Extensions.OpenAI.Kusto)
[![NuGet](https://img.shields.io/nuget/v/Microsoft.Azure.WebJobs.Extensions.OpenAI.AzureAISearch.svg?label=microsoft.azure.webjobs.extensions.openai.azureaisearch)](https://www.nuget.org/packages/Microsoft.Azure.WebJobs.Extensions.OpenAI.AzureAISearch) +[![NuGet](https://img.shields.io/nuget/v/Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Kusto.svg?label=microsoft.azure.functions.worker.extensions.openAI.kusto)](https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Kusto) +[![NuGet](https://img.shields.io/nuget/v/Microsoft.Azure.Functions.Worker.Extensions.OpenAI.AzureAISearch.svg?label=microsoft.azure.functions.worker.extensions.openAI.azureAISearch)](https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker.Extensions.OpenAI.AzureAISearch) ## Requirements @@ -269,36 +271,60 @@ The semantic search feature allows you to import documents into a vector databas More may be added over time. -#### [C# document storage example](./samples/rag-aisearch/csharp-inproc/FilePrompt.cs) +#### [C# document storage example](./samples/rag-aisearch/csharp-ooproc/FilePrompt.cs) This HTTP trigger function takes a path to a local file as input, generates embeddings for the file, and stores the result into an Azure AI Search Index. ```csharp -public record EmbeddingsRequest(string FilePath); +public class EmbeddingsRequest +{ + [JsonPropertyName("FilePath")] + public string? FilePath { get; set; } +} -[FunctionName("IngestFile")] -public static async Task IngestFile( - [HttpTrigger(AuthorizationLevel.Function, "post")] EmbeddingsRequest req, - [Embeddings("{FilePath}", InputType.FilePath, Model = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] EmbeddingsContext embeddings, - [SemanticSearch("AISearchEndpoint", "openai-index", CredentialSettingName = "SearchAPIKey", ChatModel = "%CHAT_MODEL_DEPLOYMENT_NAME%", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] IAsyncCollector output) +[Function("IngestFile")] +public static async Task IngestFile( + [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req, + [EmbeddingsInput("{FilePath}", InputType.FilePath, Model = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] EmbeddingsContext embeddings) { - string title = Path.GetFileNameWithoutExtension(req.FilePath); - await output.AddAsync(new SearchableDocument(title, embeddings)); - return new OkObjectResult(new { status = "success", title, chunks = embeddings.Count }); + using StreamReader reader = new(req.Body); + string request = await reader.ReadToEndAsync(); + + EmbeddingsRequest? requestBody = JsonSerializer.Deserialize(request); + + if (requestBody == null) + { + throw new ArgumentException("Invalid request body. Make sure that you pass in {\"filePath\": value } as the request body."); + } + + string title = Path.GetFileNameWithoutExtension(requestBody.FilePath); + + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { status = "success", title, chunks = embeddings.Count }); + + return new SemanticSearchOutputResponse + { + HttpResponse = response, + SearchableDocument = new SearchableDocument(title, embeddings) + }; } ``` -#### [C# document query example](./samples/rag-aisearch/csharp-inproc/FilePrompt.cs) +#### [C# document query example](./samples/rag-aisearch/csharp-ooproc/FilePrompt.cs) This HTTP trigger function takes a query prompt as input, pulls in semantically similar document chunks into a prompt, and then sends the combined prompt to OpenAI. The results are then made available to the function, which simply returns that chat response to the caller. ```csharp -public record SemanticSearchRequest(string Prompt); +public class SemanticSearchRequest +{ + [JsonPropertyName("Prompt")] + public string? Prompt { get; set; } +} -[FunctionName("PromptFile")] +[Function("PromptFile")] public static IActionResult PromptFile( [HttpTrigger(AuthorizationLevel.Function, "post")] SemanticSearchRequest unused, - [SemanticSearch("AISearchEndpoint", "openai-index", CredentialSettingName = "SearchAPIKey", Query = "{Prompt}", ChatModel = "%CHAT_MODEL_DEPLOYMENT_NAME%", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] SemanticSearchContext result) + [SemanticSearchInput("AISearchEndpoint", "openai-index", CredentialSettingName = "SearchAPIKey", Query = "{Prompt}", ChatModel = "%CHAT_MODEL_DEPLOYMENT_NAME%", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] SemanticSearchContext result) { return new ContentResult { Content = result.Response, ContentType = "text/plain" }; } @@ -331,7 +357,7 @@ All samples in this project rely on default model selection, which assumes the m ```csharp // "gpt-35-turbo" is the name of an Azure OpenAI deployment -[FunctionName(nameof(WhoIs))] +[Function(nameof(WhoIs))] public static string WhoIs( [HttpTrigger(AuthorizationLevel.Function, Route = "whois/{name}")] HttpRequest req, [TextCompletion("Who is {name}?", Model = "gpt-35-turbo")] TextCompletionResponse response) @@ -344,7 +370,7 @@ public static string WhoIs( public record SemanticSearchRequest(string Prompt); // "my-gpt-4" and "my-ada-2" are the names of Azure OpenAI deployments corresponding to gpt-4 and text-embedding-3-small models, respectively -[FunctionName("PromptEmail")] +[Function("PromptEmail")] public static IActionResult PromptEmail( [HttpTrigger(AuthorizationLevel.Function, "post")] SemanticSearchRequest unused, [SemanticSearch("KustoConnectionString", "Documents", Query = "{Prompt}", ChatModel = "my-gpt-4", EmbeddingsModel = "my-ada-2")] SemanticSearchContext result) diff --git a/build/build-pr-ci.yml b/build/build-pr-ci.yml index 10ce5722..3dbbb304 100644 --- a/build/build-pr-ci.yml +++ b/build/build-pr-ci.yml @@ -60,17 +60,21 @@ steps: displayName: Create new directory - script: | - dotnet build $(System.DefaultWorkingDirectory)/src/WebJobs.Extensions.OpenAI/WebJobs.Extensions.OpenAI.csproj --configuration $(config) -p:WebJobsVersion=$(fakeWebJobsPackageVersion) -p:Version=$(fakeWebJobsPackageVersion) + dotnet build $(System.DefaultWorkingDirectory)/src/WebJobs.Extensions.OpenAI/WebJobs.Extensions.OpenAI.csproj --configuration $(config) -p:WebJobsVersion=$(fakeWebJobsPackageVersion) -p:Version=$(fakeWebJobsPackageVersion) -p:AzureAISearchVersion=$(fakeWebJobsPackageVersion) -p:KustoVersion=$(KustoVersion) + dotnet build $(System.DefaultWorkingDirectory)/src/WebJobs.Extensions.OpenAI.Kusto/WebJobs.Extensions.OpenAI.Kusto.csproj --configuration $(config) -p:Version=$(fakeWebJobsPackageVersion) -p:AzureAISearchVersion=$(fakeWebJobsPackageVersion) -p:KustoVersion=$(KustoVersion) + dotnet build $(System.DefaultWorkingDirectory)/src/WebJobs.Extensions.OpenAI.AzureAISearch/WebJobs.Extensions.OpenAI.AzureAISearch.csproj --configuration $(config) -p:Version=$(fakeWebJobsPackageVersion) -p:AzureAISearchVersion=$(fakeWebJobsPackageVersion) -p:KustoVersion=$(KustoVersion) sudo cp "$(System.DefaultWorkingDirectory)/src/WebJobs.Extensions.OpenAI/bin/Release/Microsoft.Azure.WebJobs.Extensions.OpenAI.$(fakeWebJobsPackageVersion).nupkg" ~/NuGetPackagesLocal + sudo cp "$(System.DefaultWorkingDirectory)/src/WebJobs.Extensions.OpenAI.AzureAISearch/bin/Release/Microsoft.Azure.WebJobs.Extensions.OpenAI.AzureAISearch.$(fakeWebJobsPackageVersion).nupkg" ~/NuGetPackagesLocal + sudo cp "$(System.DefaultWorkingDirectory)/src/WebJobs.Extensions.OpenAI.Kusto/bin/Release/Microsoft.Azure.WebJobs.Extensions.OpenAI.Kusto.$(fakeWebJobsPackageVersion).nupkg" ~/NuGetPackagesLocal sleep 10s - - dotnet nuget add source ~/NuGetPackagesLocal - dotnet nuget list source + + dotnet nuget add source ~/NuGetPackagesLocal --configfile ~/.nuget/NuGet/NuGet.Config + dotnet nuget list source --configfile ~/.nuget/NuGet/NuGet.Config sleep 10s - dotnet build --configuration $(config) /p:WebJobsVersion=$(fakeWebJobsPackageVersion) + dotnet build --configuration $(config) /p:WebJobsVersion=$(fakeWebJobsPackageVersion) /p:Version=$(fakeWebJobsPackageVersion) /p:AzureAISearchVersion=$(fakeWebJobsPackageVersion) /p:KustoVersion=$(fakeWebJobsPackageVersion) --configfile ~/.nuget/NuGet/NuGet.Config displayName: Create/Copy NuGet Package to local directory and Build - script: dotnet test --configuration $(config) --no-build --verbosity normal diff --git a/samples/rag-aisearch/csharp-inproc/FilePrompt.cs b/samples/rag-aisearch/csharp-inproc/FilePrompt.cs deleted file mode 100644 index 270e2e09..00000000 --- a/samples/rag-aisearch/csharp-inproc/FilePrompt.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.IO; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Extensions.Http; -using Microsoft.Azure.WebJobs.Extensions.OpenAI.Embeddings; -using Microsoft.Azure.WebJobs.Extensions.OpenAI.Search; - -namespace SemanticAISearchEmbeddings; - -public static class FilePrompt -{ - public record EmbeddingsRequest(string FilePath); - public record SemanticSearchRequest(string Prompt); - - // REVIEW: There are several assumptions about how the Embeddings binding and the SemanticSearch bindings - // work together. We should consider creating a higher-level of abstraction for this. - [FunctionName("IngestFile")] - public static async Task IngestFile( - [HttpTrigger(AuthorizationLevel.Function, "post")] EmbeddingsRequest req, - [Embeddings("{FilePath}", InputType.FilePath, Model = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] EmbeddingsContext embeddings, - [SemanticSearch("AISearchEndpoint", "openai-index", CredentialSettingName = "SearchAPIKey", ChatModel = "%CHAT_MODEL_DEPLOYMENT_NAME%", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] IAsyncCollector output) - { - string title = Path.GetFileNameWithoutExtension(req.FilePath); - await output.AddAsync(new SearchableDocument(title, embeddings)); - return new OkObjectResult(new { status = "success", title, chunks = embeddings.Count }); - } - - [FunctionName("PromptFile")] - public static IActionResult PromptFile( - [HttpTrigger(AuthorizationLevel.Function, "post")] SemanticSearchRequest unused, - [SemanticSearch("AISearchEndpoint", "openai-index", CredentialSettingName = "SearchAPIKey", Query = "{Prompt}", ChatModel = "%CHAT_MODEL_DEPLOYMENT_NAME%", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] SemanticSearchContext result) - { - return new ContentResult { Content = result.Response, ContentType = "text/plain" }; - } -} diff --git a/samples/rag-aisearch/csharp-inproc/SemanticAISearchEmbeddings.csproj b/samples/rag-aisearch/csharp-inproc/SemanticAISearchEmbeddings.csproj deleted file mode 100644 index b9403523..00000000 --- a/samples/rag-aisearch/csharp-inproc/SemanticAISearchEmbeddings.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - net6.0 - v4 - - - - - - - - - - - PreserveNewest - - - PreserveNewest - Never - - - diff --git a/samples/rag-aisearch/csharp-ooproc/FilePrompt.cs b/samples/rag-aisearch/csharp-ooproc/FilePrompt.cs new file mode 100644 index 00000000..4a4fba68 --- /dev/null +++ b/samples/rag-aisearch/csharp-ooproc/FilePrompt.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Embeddings; +using Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Search; +using Microsoft.Azure.Functions.Worker.Http; + +namespace SemanticAISearchEmbeddings; + +public static class FilePrompt +{ + public class EmbeddingsRequest + { + [JsonPropertyName("FilePath")] + public string? FilePath { get; set; } + } + + public class SemanticSearchRequest + { + [JsonPropertyName("Prompt")] + public string? Prompt { get; set; } + } + + // REVIEW: There are several assumptions about how the Embeddings binding and the SemanticSearch bindings + // work together. We should consider creating a higher-level of abstraction for this. + [Function("IngestFile")] + public static async Task IngestFile( + [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req, + [EmbeddingsInput("{FilePath}", InputType.FilePath, Model = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] EmbeddingsContext embeddings) + { + using StreamReader reader = new(req.Body); + string request = await reader.ReadToEndAsync(); + + EmbeddingsRequest? requestBody = JsonSerializer.Deserialize(request); + + if (requestBody == null) + { + throw new ArgumentException("Invalid request body. Make sure that you pass in {\"filePath\": value } as the request body."); + } + + string title = Path.GetFileNameWithoutExtension(requestBody.FilePath); + + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { status = "success", title, chunks = embeddings.Count }); + + return new SemanticSearchOutputResponse + { + HttpResponse = response, + SearchableDocument = new SearchableDocument(title, embeddings) + }; + } + + public class SemanticSearchOutputResponse + { + [SemanticSearchOutput("AISearchEndpoint", "openai-index", CredentialSettingName = "SearchAPIKey", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] + public SearchableDocument SearchableDocument { get; set; } + + public HttpResponseData? HttpResponse { get; set; } + } + + [Function("PromptFile")] + public static IActionResult PromptFile( + [HttpTrigger(AuthorizationLevel.Function, "post")] SemanticSearchRequest unused, + [SemanticSearchInput("AISearchEndpoint", "openai-index", CredentialSettingName = "SearchAPIKey", Query = "{Prompt}", ChatModel = "%CHAT_MODEL_DEPLOYMENT_NAME%", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] SemanticSearchContext result) + { + return new ContentResult { Content = result.Response, ContentType = "text/plain" }; + } +} diff --git a/samples/rag-aisearch/csharp-ooproc/Program.cs b/samples/rag-aisearch/csharp-ooproc/Program.cs new file mode 100644 index 00000000..107dbf94 --- /dev/null +++ b/samples/rag-aisearch/csharp-ooproc/Program.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .ConfigureServices(services => + { + services.AddApplicationInsightsTelemetryWorkerService(); + services.ConfigureFunctionsApplicationInsights(); + }) + .Build(); + +host.Run(); diff --git a/samples/rag-aisearch/csharp-ooproc/SemanticAISearchEmbeddings.csproj b/samples/rag-aisearch/csharp-ooproc/SemanticAISearchEmbeddings.csproj new file mode 100644 index 00000000..27f18bf8 --- /dev/null +++ b/samples/rag-aisearch/csharp-ooproc/SemanticAISearchEmbeddings.csproj @@ -0,0 +1,33 @@ + + + net6.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + \ No newline at end of file diff --git a/samples/rag-aisearch/csharp-inproc/host.json b/samples/rag-aisearch/csharp-ooproc/host.json similarity index 100% rename from samples/rag-aisearch/csharp-inproc/host.json rename to samples/rag-aisearch/csharp-ooproc/host.json diff --git a/samples/rag-aisearch/csharp-inproc/local.settings.json b/samples/rag-aisearch/csharp-ooproc/local.settings.json similarity index 88% rename from samples/rag-aisearch/csharp-inproc/local.settings.json rename to samples/rag-aisearch/csharp-ooproc/local.settings.json index 1ac539c4..35808050 100644 --- a/samples/rag-aisearch/csharp-inproc/local.settings.json +++ b/samples/rag-aisearch/csharp-ooproc/local.settings.json @@ -2,7 +2,7 @@ "IsEncrypted": false, "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "FUNCTIONS_WORKER_RUNTIME": "dotnet", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", "SearchAPIKey": "", "AISearchEndpoint": "https://.search.windows.net", "AZURE_OPENAI_ENDPOINT": "https://.openai.azure.com/", diff --git a/samples/rag/README.md b/samples/rag-kusto/README.md similarity index 97% rename from samples/rag/README.md rename to samples/rag-kusto/README.md index bb0d7b80..6c6ef4f6 100644 --- a/samples/rag/README.md +++ b/samples/rag-kusto/README.md @@ -4,7 +4,7 @@ The sample is available in the following language stacks: -* [C# on the in process worker](csharp-inproc/) +* [C# on the out of process worker](csharp-ooproc/) Please refer to the [root README](../../README.md#requirements) for common prerequisites that apply to all samples. diff --git a/samples/rag-kusto/csharp-ooproc/EmailPromptDemo.cs b/samples/rag-kusto/csharp-ooproc/EmailPromptDemo.cs new file mode 100644 index 00000000..3552e712 --- /dev/null +++ b/samples/rag-kusto/csharp-ooproc/EmailPromptDemo.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Embeddings; +using Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Search; +using Microsoft.Azure.Functions.Worker.Http; + +namespace SemanticSearchEmbeddings; + +public class EmailPromptDemo +{ + public class EmbeddingsRequest + { + [JsonPropertyName("FilePath")] + public string? FilePath { get; set; } + } + + public class SemanticSearchRequest + { + [JsonPropertyName("Prompt")] + public string? Prompt { get; set; } + } + + // REVIEW: There are several assumptions about how the Embeddings binding and the SemanticSearch bindings + // work together. We should consider creating a higher-level of abstraction for this. + [Function("IngestEmail")] + public async Task IngestEmail( + [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req, + [EmbeddingsInput("{FilePath}", InputType.FilePath, Model = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] EmbeddingsContext embeddings) + { + using StreamReader reader = new(req.Body); + string request = await reader.ReadToEndAsync(); + + EmbeddingsRequest? requestBody = JsonSerializer.Deserialize(request); + + if (requestBody == null) + { + throw new ArgumentException("Invalid request body. Make sure that you pass in {\"filePath\": value } as the request body."); + } + + string title = Path.GetFileNameWithoutExtension(requestBody.FilePath); + + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { status = "success", title, chunks = embeddings.Count }); + + return new SemanticSearchOutputResponse + { + HttpResponse = response, + SearchableDocument = new SearchableDocument(title, embeddings) + }; + } + + public class SemanticSearchOutputResponse + { + [SemanticSearchOutput("KustoConnectionString", "Documents", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] + public SearchableDocument SearchableDocument { get; set; } + + public HttpResponseData? HttpResponse { get; set; } + } + + [Function("PromptEmail")] + public IActionResult PromptEmail( + [HttpTrigger(AuthorizationLevel.Function, "post")] SemanticSearchRequest unused, + [SemanticSearchInput("KustoConnectionString", "Documents", Query = "{Prompt}", ChatModel = "%CHAT_MODEL_DEPLOYMENT_NAME%", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] SemanticSearchContext result) + { + return new ContentResult { Content = result.Response, ContentType = "text/plain" }; + } +} diff --git a/samples/rag-kusto/csharp-ooproc/Program.cs b/samples/rag-kusto/csharp-ooproc/Program.cs new file mode 100644 index 00000000..107dbf94 --- /dev/null +++ b/samples/rag-kusto/csharp-ooproc/Program.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .ConfigureServices(services => + { + services.AddApplicationInsightsTelemetryWorkerService(); + services.ConfigureFunctionsApplicationInsights(); + }) + .Build(); + +host.Run(); diff --git a/samples/rag/csharp-inproc/Properties/launchSettings.json b/samples/rag-kusto/csharp-ooproc/Properties/launchSettings.json similarity index 100% rename from samples/rag/csharp-inproc/Properties/launchSettings.json rename to samples/rag-kusto/csharp-ooproc/Properties/launchSettings.json diff --git a/samples/rag-kusto/csharp-ooproc/SemanticSearchEmbeddings.csproj b/samples/rag-kusto/csharp-ooproc/SemanticSearchEmbeddings.csproj new file mode 100644 index 00000000..04bd099c --- /dev/null +++ b/samples/rag-kusto/csharp-ooproc/SemanticSearchEmbeddings.csproj @@ -0,0 +1,33 @@ + + + net6.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + \ No newline at end of file diff --git a/samples/rag/csharp-inproc/demo.http b/samples/rag-kusto/csharp-ooproc/demo.http similarity index 100% rename from samples/rag/csharp-inproc/demo.http rename to samples/rag-kusto/csharp-ooproc/demo.http diff --git a/samples/rag/csharp-inproc/host.json b/samples/rag-kusto/csharp-ooproc/host.json similarity index 100% rename from samples/rag/csharp-inproc/host.json rename to samples/rag-kusto/csharp-ooproc/host.json diff --git a/samples/rag/csharp-inproc/local.settings.json b/samples/rag-kusto/csharp-ooproc/local.settings.json similarity index 88% rename from samples/rag/csharp-inproc/local.settings.json rename to samples/rag-kusto/csharp-ooproc/local.settings.json index ae7afa23..58ab015f 100644 --- a/samples/rag/csharp-inproc/local.settings.json +++ b/samples/rag-kusto/csharp-ooproc/local.settings.json @@ -2,7 +2,7 @@ "IsEncrypted": false, "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "FUNCTIONS_WORKER_RUNTIME": "dotnet", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", "KustoConnectionString": "Data Source=https://YOUR-CLUSTER-HOSTNAME;Initial Catalog=YOUR-DATABASE-NAME;Fed=True", "AZURE_OPENAI_ENDPOINT": "https://.openai.azure.com/", "CHAT_MODEL_DEPLOYMENT_NAME": "gpt-3.5-turbo", diff --git a/samples/rag/csharp-inproc/EmailPromptDemo.cs b/samples/rag/csharp-inproc/EmailPromptDemo.cs deleted file mode 100644 index 697fa0aa..00000000 --- a/samples/rag/csharp-inproc/EmailPromptDemo.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.IO; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Extensions.Http; -using Microsoft.Azure.WebJobs.Extensions.OpenAI.Embeddings; -using Microsoft.Azure.WebJobs.Extensions.OpenAI.Search; - -namespace SemanticSearchEmbeddings; - -public static class EmailPromptDemo -{ - public record EmbeddingsRequest(string FilePath); - public record SemanticSearchRequest(string Prompt); - - // REVIEW: There are several assumptions about how the Embeddings binding and the SemanticSearch bindings - // work together. We should consider creating a higher-level of abstraction for this. - [FunctionName("IngestEmail")] - public static async Task IngestEmail( - [HttpTrigger(AuthorizationLevel.Function, "post")] EmbeddingsRequest req, - [Embeddings("{FilePath}", InputType.FilePath, Model = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] EmbeddingsContext embeddings, - [SemanticSearch("KustoConnectionString", "Documents", ChatModel = "%CHAT_MODEL_DEPLOYMENT_NAME%", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] IAsyncCollector output) - { - string title = Path.GetFileNameWithoutExtension(req.FilePath); - await output.AddAsync(new SearchableDocument(title, embeddings)); - return new OkObjectResult(new { status = "success", title, chunks = embeddings.Count }); - } - - [FunctionName("PromptEmail")] - public static IActionResult PromptEmail( - [HttpTrigger(AuthorizationLevel.Function, "post")] SemanticSearchRequest unused, - [SemanticSearch("KustoConnectionString", "Documents", Query = "{Prompt}", ChatModel = "%CHAT_MODEL_DEPLOYMENT_NAME%", EmbeddingsModel = "%EMBEDDING_MODEL_DEPLOYMENT_NAME%")] SemanticSearchContext result) - { - return new ContentResult { Content = result.Response, ContentType = "text/plain" }; - } -} diff --git a/samples/rag/csharp-inproc/SemanticSearchEmbeddings.csproj b/samples/rag/csharp-inproc/SemanticSearchEmbeddings.csproj deleted file mode 100644 index bbd6b82b..00000000 --- a/samples/rag/csharp-inproc/SemanticSearchEmbeddings.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - net6.0 - v4 - - - - - - - - - - - PreserveNewest - - - PreserveNewest - Never - - - diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 76d26407..ef2a7c3c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -31,6 +31,10 @@ $(VersionPrefix).0 $(VersionPrefix).$(FileVersionRevision) + + 0.13.0-alpha + + 0.1.0-alpha diff --git a/src/Functions.Worker.Extensions.OpenAI.AzureAISearch/Functions.Worker.Extensions.OpenAI.AzureAISearch.csproj b/src/Functions.Worker.Extensions.OpenAI.AzureAISearch/Functions.Worker.Extensions.OpenAI.AzureAISearch.csproj new file mode 100644 index 00000000..44663c06 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI.AzureAISearch/Functions.Worker.Extensions.OpenAI.AzureAISearch.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.1 + Microsoft Azure Functions .NET Isolated Worker Extension for OpenAI Azure AI Search + $(AzureAISearchVersion) + + + + + + + + + <_Parameter1>Microsoft.Azure.WebJobs.Extensions.OpenAI.AzureAISearch + <_Parameter2>$(AzureAISearchVersion) + <_Parameter3>true + <_Parameter3_IsLiteral>true + + + + diff --git a/src/Functions.Worker.Extensions.OpenAI.Kusto/Functions.Worker.Extensions.OpenAI.Kusto.csproj b/src/Functions.Worker.Extensions.OpenAI.Kusto/Functions.Worker.Extensions.OpenAI.Kusto.csproj new file mode 100644 index 00000000..e4a59d53 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI.Kusto/Functions.Worker.Extensions.OpenAI.Kusto.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.1 + Microsoft Azure Functions .NET Isolated Worker Extension for OpenAI Kusto + $(KustoVersion) + + + + + + + + + <_Parameter1>Microsoft.Azure.WebJobs.Extensions.OpenAI.Kusto + <_Parameter2>$(KustoVersion) + <_Parameter3>true + <_Parameter3_IsLiteral>true + + + + diff --git a/src/Functions.Worker.Extensions.OpenAI/Assistants/ChatCompletionJsonConverter.cs b/src/Functions.Worker.Extensions.OpenAI/Assistants/ChatCompletionJsonConverter.cs new file mode 100644 index 00000000..a73974fa --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Assistants/ChatCompletionJsonConverter.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.AI.OpenAI; +using System.ClientModel.Primitives; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Assistants; +public class ChatCompletionsJsonConverter : JsonConverter +{ + static readonly ModelReaderWriterOptions modelReaderWriterOptions = new("J"); + public override ChatCompletions Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using JsonDocument jsonDocument = JsonDocument.ParseValue(ref reader); + return ModelReaderWriter.Read(BinaryData.FromString(jsonDocument.RootElement.GetRawText()))!; + } + + public override void Write(Utf8JsonWriter writer, ChatCompletions value, JsonSerializerOptions options) + { + ((IJsonModel)value).Write(writer, modelReaderWriterOptions); + } +} \ No newline at end of file diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/ConnectionInfo.cs b/src/Functions.Worker.Extensions.OpenAI/Search/ConnectionInfo.cs new file mode 100644 index 00000000..ae709409 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/ConnectionInfo.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Search; + +/// +/// Connection info object. +/// +public class ConnectionInfo +{ + /// + /// Connection info containing connection name, collection name, and credentials. + /// + /// + /// The name of an app setting or environment variable which contains a connection string value. + /// + /// + /// The name of the collection or table to search or store. + /// + /// + /// Credentials for authenticating with the search provider. + /// + public ConnectionInfo(string connectionName, string collectionName, string? credentials) + { + this.ConnectionName = connectionName; + this.CollectionName = collectionName; + this.Credentials = credentials; + } + + /// + /// Gets or sets the name of an app setting or environment variable which contains a connection string value. + /// + public string ConnectionName { get; set; } + + /// + /// The name of the collection or table to search. + /// + public string CollectionName { get; set; } + + /// + /// The name of the app setting or environment variable containing the required credentials + /// for authenticating with the search provider. See the documentation for the search provider + /// extension to know what format the underlying credential value requires. + /// + public string? Credentials { get; set; } +} diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/SearchableDocument.cs b/src/Functions.Worker.Extensions.OpenAI/Search/SearchableDocument.cs new file mode 100644 index 00000000..e7902ce9 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/SearchableDocument.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Embeddings; + +namespace Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Search; + +/// +/// Searchable document which contains the title and embeddings context. +/// +public class SearchableDocument +{ + /// + /// Searchable document containing the title and embeddings context. + /// + /// + /// Title of the searchable document. + /// + /// + /// The embeddings context associated with the searchable document. + /// + public SearchableDocument(string title, EmbeddingsContext embeddingsContext) + { + this.Title = title; + this.EmbeddingsContext = embeddingsContext; + } + + /// + /// Connection info for the searchable document. + /// + public ConnectionInfo? ConnectionInfo { get; set; } + + /// + /// Title of the searchable document. + /// + public string Title { get; } + + /// + /// Embeddings context that contains embedings request and response from OpenAI for searchable document. + /// + public EmbeddingsContext EmbeddingsContext { get; } +} diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/SearchableDocumentJsonConverter.cs b/src/Functions.Worker.Extensions.OpenAI/Search/SearchableDocumentJsonConverter.cs new file mode 100644 index 00000000..3e1116b1 --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/SearchableDocumentJsonConverter.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenAISDK = Azure.AI.OpenAI; + +namespace Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Search; + +class SearchableDocumentJsonConverter : JsonConverter +{ + static readonly ModelReaderWriterOptions modelReaderWriterOptions = new("J"); + public override SearchableDocument Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, SearchableDocument value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WritePropertyName("embeddingsContext"u8); + writer.WriteStartObject(); + + writer.WritePropertyName("request"u8); + ((IJsonModel)value.EmbeddingsContext.Request).Write(writer, modelReaderWriterOptions); + + writer.WritePropertyName("response"u8); + ((IJsonModel)value.EmbeddingsContext.Response).Write(writer, modelReaderWriterOptions); + + writer.WritePropertyName("count"u8); + writer.WriteNumberValue(value.EmbeddingsContext.Count); + writer.WriteEndObject(); + + writer.WritePropertyName("connectionInfo"u8); + writer.WriteStartObject(); + writer.WritePropertyName("connectionName"u8); + + if (value.ConnectionInfo == null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value.ConnectionInfo.ConnectionName); + } + + writer.WritePropertyName("collectionName"u8); + + if (value.ConnectionInfo == null) + { + writer.WriteNullValue(); + } + else if (value.ConnectionInfo.CollectionName == null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value.ConnectionInfo.CollectionName); + } + writer.WriteEndObject(); + + + writer.WritePropertyName("title"); + writer.WriteStringValue(value.Title); + writer.WriteEndObject(); + } +} diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchContext.cs b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchContext.cs new file mode 100644 index 00000000..1258442d --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchContext.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.AI.OpenAI; +using Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Embeddings; + +namespace Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Search; + +/// +/// Input binding target for semantic search. +/// +public class SemanticSearchContext +{ + /// + /// Input binding target for the . + /// + /// The embeddings context associated with the semantic search. + /// The chat response from the large language model. + public SemanticSearchContext(EmbeddingsContext Embeddings, ChatCompletions Chat) + { + this.Embeddings = Embeddings; + this.Chat = Chat; + + } + + /// + /// Gets the embeddings context associated with the semantic search. + /// + public EmbeddingsContext Embeddings { get; } + + /// + /// Gets the chat response from the large language model. + /// + public ChatCompletions Chat { get; } + + + /// + /// Gets the latest response message from the OpenAI Chat API. + /// + public string Response => this.Chat.Choices.Last().Message.Content; + } diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchInputAttribute.cs b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchInputAttribute.cs new file mode 100644 index 00000000..fac171aa --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchInputAttribute.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; + +namespace Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Search; + +/// +/// Binding attribute for semantic search (input bindings). +/// +public class SemanticSearchInputAttribute : InputBindingAttribute +{ + /// + /// Initializes a new instance of the class with the specified connection + /// and collection names. + /// + /// + /// The name of an app setting or environment variable which contains a connection string value. + /// + /// The name of the collection or table to search or store. + /// + /// Thrown if either or are null. + /// + public SemanticSearchInputAttribute(string connectionName, string collection) + { + this.ConnectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName)); + this.Collection = collection ?? throw new ArgumentNullException(nameof(collection)); + } + + /// + /// Gets or sets the name of an app setting or environment variable which contains a connection string value. + /// + /// + /// This property supports binding expressions. + /// + public string ConnectionName { get; set; } + + /// + /// The name of the collection or table to search. + /// + /// + /// This property supports binding expressions. + /// + public string Collection { get; set; } + + /// + /// The name of the app setting or environment variable containing the required credentials + /// for authenticating with the search provider. See the documentation for the search provider + /// extension to know what format the underlying credential value requires. + /// + /// + /// This property supports binding expressions. + /// + public string? CredentialSettingName { get; set; } + + /// + /// Gets or sets the semantic query text to use for searching. + /// This property is only used for the semantic search input binding. + /// + /// + /// This property supports binding expressions. + /// + public string? Query { get; set; } + + /// + /// Gets or sets the ID of the model to use for embeddings. + /// The default value is "text-embedding-3-small". + /// + /// + /// This property supports binding expressions. + /// + public string EmbeddingsModel { get; set; } = OpenAIModels.DefaultEmbeddingsModel; + + /// + /// Gets or sets the name of the Large Language Model to invoke for chat responses. + /// The default value is "gpt-3.5-turbo". + /// + /// + /// This property supports binding expressions. + /// + public string ChatModel { get; set; } = OpenAIModels.DefaultChatModel; + + /// + /// Gets or sets the system prompt to use for prompting the large language model. + /// + /// + /// + /// The system prompt will be appended with knowledge that is fetched as a result of the . + /// The combined prompt will then be sent to the OpenAI Chat API. + /// + /// This property supports binding expressions. + /// + /// + public string SystemPrompt { get; set; } = """ + You are a helpful assistant. You are responding to requests from a user about internal emails and documents. + You can and should refer to the internal documents to help respond to requests. If a user makes a request that's + not covered by the internal emails and documents, explain that you don't know the answer or that you don't have + access to the information. + + The following is a list of documents that you can refer to when answering questions. The documents are in the format + [filename]: [text] and are separated by newlines. If you answer a question by referencing any of the documents, + please cite the document in your answer. For example, if you answer a question by referencing info.txt, + you should add "Reference: info.txt" to the end of your answer on a separate line. + + """; + + /// + /// Gets or sets the number of knowledge items to inject into the . + /// + public int MaxKnowledgeCount { get; set; } = 1; +} diff --git a/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchOutputAttribute.cs b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchOutputAttribute.cs new file mode 100644 index 00000000..b00f140c --- /dev/null +++ b/src/Functions.Worker.Extensions.OpenAI/Search/SemanticSearchOutputAttribute.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; + +namespace Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Search; + +/// +/// Binding attribute for semantic document storage (output bindings). +/// +public class SemanticSearchOutputAttribute : OutputBindingAttribute +{ + /// + /// Initializes a new instance of the class with the specified connection + /// and collection names. + /// + /// + /// The name of an app setting or environment variable which contains a connection string value. + /// + /// The name of the collection or table to search or store. + /// + /// Thrown if either or are null. + /// + public SemanticSearchOutputAttribute(string connectionName, string collection) + { + this.ConnectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName)); + this.Collection = collection ?? throw new ArgumentNullException(nameof(collection)); + } + + /// + /// Gets or sets the name of an app setting or environment variable which contains a connection string value. + /// + /// + /// This property supports binding expressions. + /// + public string ConnectionName { get; set; } + + /// + /// The name of the collection or table to search. + /// + /// + /// This property supports binding expressions. + /// + public string Collection { get; set; } + + /// + /// The name of the app setting or environment variable containing the required credentials + /// for authenticating with the search provider. See the documentation for the search provider + /// extension to know what format the underlying credential value requires. + /// + /// + /// This property supports binding expressions. + /// + public string? CredentialSettingName { get; set; } + + /// + /// Gets or sets the ID of the model to use for embeddings. + /// The default value is "text-embedding-3-small". + /// + /// + /// This property supports binding expressions. + /// + public string EmbeddingsModel { get; set; } = OpenAIModels.DefaultEmbeddingsModel; +} diff --git a/src/Functions.Worker.Extensions.OpenAI/Startup.cs b/src/Functions.Worker.Extensions.OpenAI/Startup.cs index a1d1ba90..8bbd524b 100644 --- a/src/Functions.Worker.Extensions.OpenAI/Startup.cs +++ b/src/Functions.Worker.Extensions.OpenAI/Startup.cs @@ -4,7 +4,9 @@ using System.Text.Json; using Microsoft.Azure.Functions.Worker.Core; using Microsoft.Azure.Functions.Worker.Extensions.OpenAI; +using Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Assistants; using Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Embeddings; +using Microsoft.Azure.Functions.Worker.Extensions.OpenAI.Search; using Microsoft.Extensions.DependencyInjection; [assembly: WorkerExtensionStartup(typeof(Startup))] @@ -22,6 +24,8 @@ public override void Configure(IFunctionsWorkerApplicationBuilder applicationBui { jsonSerializerOptions.Converters.Add(new EmbeddingsJsonConverter()); jsonSerializerOptions.Converters.Add(new EmbeddingsOptionsJsonConverter()); + jsonSerializerOptions.Converters.Add(new SearchableDocumentJsonConverter()); + jsonSerializerOptions.Converters.Add(new ChatCompletionsJsonConverter()); }); } } diff --git a/src/WebJobs.Extensions.OpenAI.AzureAISearch/WebJobs.Extensions.OpenAI.AzureAISearch.csproj b/src/WebJobs.Extensions.OpenAI.AzureAISearch/WebJobs.Extensions.OpenAI.AzureAISearch.csproj index 00ad2361..1cab077a 100644 --- a/src/WebJobs.Extensions.OpenAI.AzureAISearch/WebJobs.Extensions.OpenAI.AzureAISearch.csproj +++ b/src/WebJobs.Extensions.OpenAI.AzureAISearch/WebJobs.Extensions.OpenAI.AzureAISearch.csproj @@ -1,27 +1,15 @@  + + Microsoft Azure WebJobs SDK Extension for OpenAI - Azure AI Search Package. + $(AzureAISearchVersion) + + + + + - - Microsoft Azure WebJobs SDK Extension for OpenAI - Azure AI Search Package. - - - - 0 - 1 - 0 - $(MajorVersion).$(MinorVersion).$(PatchVersion) - alpha - $(MajorVersion).$(MinorVersion).0.0 - $(VersionPrefix).0 - - $(VersionPrefix).$(FileVersionRevision) - - - - - - - - - + + + diff --git a/src/WebJobs.Extensions.OpenAI.Kusto/WebJobs.Extensions.OpenAI.Kusto.csproj b/src/WebJobs.Extensions.OpenAI.Kusto/WebJobs.Extensions.OpenAI.Kusto.csproj index 86e0b579..ce29a34f 100644 --- a/src/WebJobs.Extensions.OpenAI.Kusto/WebJobs.Extensions.OpenAI.Kusto.csproj +++ b/src/WebJobs.Extensions.OpenAI.Kusto/WebJobs.Extensions.OpenAI.Kusto.csproj @@ -1,27 +1,16 @@  - - - Microsoft Azure WebJobs SDK Extension for OpenAI - Kusto Search Package. - - - - 0 - 13 - 0 - $(MajorVersion).$(MinorVersion).$(PatchVersion) - alpha - $(MajorVersion).$(MinorVersion).0.0 - $(VersionPrefix).0 - - $(VersionPrefix).$(FileVersionRevision) - - - - - - - - - - + + + Microsoft Azure WebJobs SDK Extension for OpenAI - Kusto Search Package. + $(KustoVersion) + + + + + + + + + + diff --git a/src/WebJobs.Extensions.OpenAI/Assistants/ChatCompletionsJsonConverter.cs b/src/WebJobs.Extensions.OpenAI/Assistants/ChatCompletionsJsonConverter.cs new file mode 100644 index 00000000..f8ad8c8c --- /dev/null +++ b/src/WebJobs.Extensions.OpenAI/Assistants/ChatCompletionsJsonConverter.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.AI.OpenAI; + +namespace Microsoft.Azure.WebJobs.Extensions.OpenAI.Assistants; +class ChatCompletionsJsonConverter : JsonConverter +{ + static readonly ModelReaderWriterOptions modelReaderWriterOptions = new("J"); + public override ChatCompletions Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, ChatCompletions value, JsonSerializerOptions options) + { + ((IJsonModel)value).Write(writer, modelReaderWriterOptions); + } +} diff --git a/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsJsonConverter.cs b/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsContextConverter.cs similarity index 94% rename from src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsJsonConverter.cs rename to src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsContextConverter.cs index 51a12948..301712fc 100644 --- a/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsJsonConverter.cs +++ b/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsContextConverter.cs @@ -11,7 +11,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.OpenAI.Embeddings; /// /// Embeddings JSON converter needed to serialize the EmbeddingsContext object. /// -class EmbeddingsJsonConverter : JsonConverter +class EmbeddingsContextConverter : JsonConverter { static readonly ModelReaderWriterOptions modelReaderWriterOptions = new("J"); public override EmbeddingsContext Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsConverter.cs b/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsConverter.cs index dc42374b..70c44951 100644 --- a/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsConverter.cs +++ b/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsConverter.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.Logging; using OpenAISDK = Azure.AI.OpenAI; - namespace Microsoft.Azure.WebJobs.Extensions.OpenAI.Embeddings; class EmbeddingsConverter : @@ -18,7 +17,7 @@ class EmbeddingsConverter : // Note: we need this converter as Azure.AI.OpenAI does not support System.Text.Json serialization since their constructors are internal static readonly JsonSerializerOptions options = new() { - Converters = { new EmbeddingsJsonConverter() } + Converters = { new EmbeddingsContextConverter() } }; public EmbeddingsConverter(OpenAISDK.OpenAIClient openAIClient, ILoggerFactory loggerFactory) diff --git a/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsOptionsJsonConverter.cs b/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsOptionsJsonConverter.cs new file mode 100644 index 00000000..e1705ac5 --- /dev/null +++ b/src/WebJobs.Extensions.OpenAI/Embeddings/EmbeddingsOptionsJsonConverter.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.AI.OpenAI; + +namespace Microsoft.Azure.WebJobs.Extensions.OpenAI.Embeddings; +class EmbeddingsOptionsJsonConverter : JsonConverter +{ + static readonly ModelReaderWriterOptions modelReaderWriterOptions = new("J"); + public override EmbeddingsOptions Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, EmbeddingsOptions value, JsonSerializerOptions options) + { + ((IJsonModel)value).Write(writer, modelReaderWriterOptions); + } +} diff --git a/src/WebJobs.Extensions.OpenAI/OpenAIExtension.cs b/src/WebJobs.Extensions.OpenAI/OpenAIExtension.cs index 42688349..44ed6b99 100644 --- a/src/WebJobs.Extensions.OpenAI/OpenAIExtension.cs +++ b/src/WebJobs.Extensions.OpenAI/OpenAIExtension.cs @@ -53,8 +53,10 @@ void IExtensionConfigProvider.Initialize(ExtensionConfigContext context) // Semantic search input binding support var semanticSearchRule = context.AddBindingRule(); semanticSearchRule.BindToInput(this.semanticSearchConverter); + semanticSearchRule.BindToInput(this.semanticSearchConverter); // TODO: Add string binding support to enable binding in non-.NET languages. semanticSearchRule.BindToCollector(this.semanticSearchConverter); + context.AddConverter(this.semanticSearchConverter.ToSearchableDocument); // Assistant support var chatBotCreateRule = context.AddBindingRule(); diff --git a/src/WebJobs.Extensions.OpenAI/Search/SearchableDocumentJsonConverter.cs b/src/WebJobs.Extensions.OpenAI/Search/SearchableDocumentJsonConverter.cs new file mode 100644 index 00000000..f127eae5 --- /dev/null +++ b/src/WebJobs.Extensions.OpenAI/Search/SearchableDocumentJsonConverter.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Azure.WebJobs.Extensions.OpenAI.Embeddings; +using OpenAISDK = Azure.AI.OpenAI; + +namespace Microsoft.Azure.WebJobs.Extensions.OpenAI.Search; +class SearchableDocumentJsonConverter : JsonConverter +{ + static readonly ModelReaderWriterOptions modelReaderWriterOptions = new("J"); + public override SearchableDocument Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using JsonDocument jsonDocument = JsonDocument.ParseValue(ref reader); + + // Properties for SearchableDocument + OpenAISDK.EmbeddingsOptions embeddingsOptions = null; + OpenAISDK.Embeddings embeddings = null; + int count; + string title = null; + string connectionName = null; + string collectionName = null; + string credentials = null; + + foreach (JsonProperty item in jsonDocument.RootElement.EnumerateObject()) + { + if (item.NameEquals("embeddingsContext"u8)) + { + foreach (JsonProperty embeddingContextItem in item.Value.EnumerateObject()) + { + if (embeddingContextItem.NameEquals("request"u8)) + { + embeddingsOptions = ModelReaderWriter.Read(BinaryData.FromString(embeddingContextItem.Value.GetRawText()))!; + } + if (embeddingContextItem.NameEquals("response"u8)) + { + embeddings = ModelReaderWriter.Read(BinaryData.FromString(embeddingContextItem.Value.GetRawText()))!; + } + if (embeddingContextItem.NameEquals("count"u8)) + { + count = embeddingContextItem.Value.GetInt32(); + } + } + } + if (item.NameEquals("connectionInfo"u8)) + { + foreach (JsonProperty connectionInfoItem in item.Value.EnumerateObject()) + { + if (connectionInfoItem.NameEquals("connectionName"u8)) + { + connectionName = connectionInfoItem.Value.GetString(); + } + if (connectionInfoItem.NameEquals("collectionName"u8)) + { + collectionName = connectionInfoItem.Value.GetString(); + } + if (connectionInfoItem.NameEquals("credentials"u8)) + { + credentials = connectionInfoItem.Value.GetString(); + } + } + } + + if (item.NameEquals("title"u8)) + { + title = item.Value.GetString(); + } + } + SearchableDocument searchableDocument = new SearchableDocument(title, new EmbeddingsContext(embeddingsOptions, embeddings)); + searchableDocument.ConnectionInfo = new ConnectionInfo(connectionName, collectionName, credentials); + return searchableDocument; + } + + public override void Write(Utf8JsonWriter writer, SearchableDocument value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WritePropertyName("embeddingsContext"u8); + writer.WriteStartObject(); + + writer.WritePropertyName("request"u8); + ((IJsonModel)value.Embeddings.Request).Write(writer, modelReaderWriterOptions); + + writer.WritePropertyName("response"u8); + ((IJsonModel)value.Embeddings.Response).Write(writer, modelReaderWriterOptions); + + writer.WritePropertyName("count"u8); + writer.WriteNumberValue(value.Embeddings.Count); + writer.WriteEndObject(); + + writer.WritePropertyName("connectionInfo"u8); + writer.WriteStartObject(); + writer.WritePropertyName("connectionName"u8); + + if (value.ConnectionInfo == null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value.ConnectionInfo.ConnectionName); + } + + writer.WritePropertyName("collectionName"u8); + + if (value.ConnectionInfo == null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value.ConnectionInfo.CollectionName); + } + + writer.WritePropertyName("credentials"u8); + + if (value.ConnectionInfo == null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value.ConnectionInfo.Credentials); + } + writer.WriteEndObject(); + + + writer.WritePropertyName("title"); + writer.WriteStringValue(value.Title); + writer.WriteEndObject(); + } +} diff --git a/src/WebJobs.Extensions.OpenAI/Search/SemanticSearchContext.cs b/src/WebJobs.Extensions.OpenAI/Search/SemanticSearchContext.cs new file mode 100644 index 00000000..4935cdc3 --- /dev/null +++ b/src/WebJobs.Extensions.OpenAI/Search/SemanticSearchContext.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.AI.OpenAI; +using Microsoft.Azure.WebJobs.Extensions.OpenAI.Embeddings; + +namespace Microsoft.Azure.WebJobs.Extensions.OpenAI.Search; + +/// +/// Input binding target for the . +/// +public class SemanticSearchContext +{ + /// + /// Input binding target for the . + /// + /// The embeddings context associated with the semantic search. + /// The chat response from the large language model. + public SemanticSearchContext(EmbeddingsContext Embeddings, ChatCompletions Chat) + { + this.Embeddings = Embeddings; + this.Chat = Chat; + + } + + /// + /// Embeddings context that contains embedings request and response from OpenAI for searchable document. + /// + public EmbeddingsContext Embeddings { get; } + + /// + /// Chat response from the chat completions request. + /// + public ChatCompletions Chat { get; } + + + /// + /// Gets the latest response message from the OpenAI Chat API. + /// + public string Response => this.Chat.Choices.Last().Message.Content; +} diff --git a/src/WebJobs.Extensions.OpenAI/Search/SemanticSearchConverter.cs b/src/WebJobs.Extensions.OpenAI/Search/SemanticSearchConverter.cs index 12bc4b9a..246587ef 100644 --- a/src/WebJobs.Extensions.OpenAI/Search/SemanticSearchConverter.cs +++ b/src/WebJobs.Extensions.OpenAI/Search/SemanticSearchConverter.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text; using Azure; +using System.Text; +using System.Text.Json; +using Microsoft.Azure.WebJobs.Extensions.OpenAI.Assistants; using Microsoft.Azure.WebJobs.Extensions.OpenAI.Embeddings; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -10,27 +12,24 @@ namespace Microsoft.Azure.WebJobs.Extensions.OpenAI.Search; -/// -/// Input binding target for the . -/// -/// The embeddings context associated with the semantic search. -/// The chat response from the large language model. -public record SemanticSearchContext(EmbeddingsContext Embeddings, Response Chat) -{ - /// - /// Gets the latest response message from the OpenAI Chat API. - /// - public string Response => this.Chat.Value.Choices.Last().Message.Content; -} - class SemanticSearchConverter : IAsyncConverter, - IAsyncConverter> + IAsyncConverter>, + IAsyncConverter { readonly OpenAISDK.OpenAIClient openAIClient; readonly ILogger logger; readonly ISearchProvider? searchProvider; + static readonly JsonSerializerOptions options = new() + { + Converters = { + new SearchableDocumentJsonConverter(), + new EmbeddingsContextConverter(), + new EmbeddingsOptionsJsonConverter(), + new ChatCompletionsJsonConverter()} + }; + public SemanticSearchConverter( OpenAISDK.OpenAIClient openAIClient, ILoggerFactory loggerFactory, @@ -60,7 +59,7 @@ public Task> ConvertAsync( return Task.FromResult(collector); } - async Task IAsyncConverter.ConvertAsync( + async Task ConvertHelperAsync( SemanticSearchAttribute attribute, CancellationToken cancellationToken) { @@ -126,6 +125,27 @@ async Task IAsyncConverter IAsyncConverter.ConvertAsync( + SemanticSearchAttribute attribute, + CancellationToken cancellationToken) + { + return await this.ConvertHelperAsync(attribute, cancellationToken); + } + + // Called by the host when processing binding requests from out-of-process workers. + internal SearchableDocument ToSearchableDocument(string json) + { + this.logger.LogDebug("Creating searchable document from JSON string: {Text}", json); + SearchableDocument document = JsonSerializer.Deserialize(json, options); + return document ?? throw new ArgumentException("Invalid assistant post request"); + } + + async Task IAsyncConverter.ConvertAsync(SemanticSearchAttribute input, CancellationToken cancellationToken) + { + SemanticSearchContext semanticSearchContext = await this.ConvertHelperAsync(input, cancellationToken); + return JsonSerializer.Serialize(semanticSearchContext, options); + } + sealed class SemanticDocumentCollector : IAsyncCollector { readonly SemanticSearchAttribute attribute; @@ -139,7 +159,7 @@ public SemanticDocumentCollector(SemanticSearchAttribute attribute, ISearchProvi public Task AddAsync(SearchableDocument item, CancellationToken cancellationToken = default) { - if (item.ConnectionInfo == null) + if (item.ConnectionInfo == null || item.ConnectionInfo.CollectionName == null || item.ConnectionInfo.CollectionName == null) { item.ConnectionInfo = new ConnectionInfo(this.attribute.ConnectionName, this.attribute.Collection, this.attribute.CredentialSettingName); } diff --git a/src/WebJobs.Extensions.OpenAI/WebJobs.Extensions.OpenAI.csproj b/src/WebJobs.Extensions.OpenAI/WebJobs.Extensions.OpenAI.csproj index 24f777ad..daac9cc5 100644 --- a/src/WebJobs.Extensions.OpenAI/WebJobs.Extensions.OpenAI.csproj +++ b/src/WebJobs.Extensions.OpenAI/WebJobs.Extensions.OpenAI.csproj @@ -8,8 +8,8 @@ - +