From 081a3d6a9ed41292523463c03a0aef7ecb1e5cd2 Mon Sep 17 00:00:00 2001 From: Stephen Hodgson Date: Sun, 18 Aug 2024 02:14:38 -0400 Subject: [PATCH] OpenAI-DotNet 8.2.0 (#347) - Added structured output support - Added support for Azure OpenAI assistants - Fixed Azure OpenAI Id parsing for events - Fixed Assistant.CreateThreadAndRunAsync to properly copy assistant parameters - Removed stream from CreateThreadAndRunRequest and CreateRunRequest - They were overridden by the presence of IStreamEventHandler anyway ## OpenAI-DotNet-Proxy 8.2.0 - Deprecated ValidateAuthentication for ValidateAuthenticationAsync --- .../OpenAI-DotNet-Proxy.csproj | 4 +- .../Proxy/AbstractAuthenticationFilter.cs | 5 +- .../Proxy/EndpointRouteBuilder.cs | 15 +- .../Proxy/IAuthenticationFilter.cs | 8 +- OpenAI-DotNet-Proxy/Readme.md | 13 +- OpenAI-DotNet-Tests-Proxy/Program.cs | 13 +- .../TestFixture_00_01_Authentication.cs | 1 + OpenAI-DotNet-Tests/TestFixture_03_Threads.cs | 80 ++++++++ OpenAI-DotNet-Tests/TestFixture_04_Chat.cs | 131 ++++++++++++- .../Assistants/AssistantExtensions.cs | 16 +- OpenAI-DotNet/Assistants/AssistantResponse.cs | 16 +- .../Assistants/CreateAssistantRequest.cs | 57 ++++-- OpenAI-DotNet/Audio/AudioEndpoint.cs | 2 + .../Authentication/OpenAIClientSettings.cs | 9 +- OpenAI-DotNet/Chat/ChatEndpoint.cs | 2 + OpenAI-DotNet/Chat/ChatRequest.cs | 43 +++-- OpenAI-DotNet/Chat/ChatResponse.cs | 6 +- OpenAI-DotNet/Chat/LogProbs.cs | 7 + OpenAI-DotNet/Chat/Message.cs | 7 + OpenAI-DotNet/Common/ChatResponseFormat.cs | 4 +- OpenAI-DotNet/Common/Function.cs | 93 +++++---- OpenAI-DotNet/Common/JsonSchema.cs | 79 ++++++++ OpenAI-DotNet/Common/OpenAIBaseEndpoint.cs | 27 ++- OpenAI-DotNet/Common/ResponseFormatObject.cs | 41 ++++ OpenAI-DotNet/Common/Tool.cs | 8 +- .../Embeddings/EmbeddingsEndpoint.cs | 2 + .../Extensions/ResponseFormatConverter.cs | 69 ------- OpenAI-DotNet/Extensions/TypeExtensions.cs | 35 ++-- OpenAI-DotNet/Images/ImagesEndpoint.cs | 2 + OpenAI-DotNet/OpenAI-DotNet.csproj | 9 +- OpenAI-DotNet/Threads/CreateRunRequest.cs | 46 +++-- .../Threads/CreateThreadAndRunRequest.cs | 47 +++-- OpenAI-DotNet/Threads/MessageResponse.cs | 2 +- OpenAI-DotNet/Threads/RunResponse.cs | 15 +- OpenAI-DotNet/Threads/RunStepResponse.cs | 2 +- README.md | 181 ++++++++++++++++-- 36 files changed, 812 insertions(+), 285 deletions(-) create mode 100644 OpenAI-DotNet/Common/JsonSchema.cs create mode 100644 OpenAI-DotNet/Common/ResponseFormatObject.cs delete mode 100644 OpenAI-DotNet/Extensions/ResponseFormatConverter.cs diff --git a/OpenAI-DotNet-Proxy/OpenAI-DotNet-Proxy.csproj b/OpenAI-DotNet-Proxy/OpenAI-DotNet-Proxy.csproj index 5cccc098..782b8fe8 100644 --- a/OpenAI-DotNet-Proxy/OpenAI-DotNet-Proxy.csproj +++ b/OpenAI-DotNet-Proxy/OpenAI-DotNet-Proxy.csproj @@ -22,8 +22,10 @@ true false false - 8.1.1 + 8.2.0 +Version 8.2.0 +- Deprecated ValidateAuthentication for ValidateAuthenticationAsync Version 8.1.1 - Renamed OpenAIProxyStartup to OpenAIProxy Version 7.7.10 diff --git a/OpenAI-DotNet-Proxy/Proxy/AbstractAuthenticationFilter.cs b/OpenAI-DotNet-Proxy/Proxy/AbstractAuthenticationFilter.cs index 44fa8829..c0d7ca75 100644 --- a/OpenAI-DotNet-Proxy/Proxy/AbstractAuthenticationFilter.cs +++ b/OpenAI-DotNet-Proxy/Proxy/AbstractAuthenticationFilter.cs @@ -1,6 +1,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using Microsoft.AspNetCore.Http; +using System; using System.Threading.Tasks; namespace OpenAI.Proxy @@ -8,8 +9,8 @@ namespace OpenAI.Proxy /// public abstract class AbstractAuthenticationFilter : IAuthenticationFilter { - /// - public abstract void ValidateAuthentication(IHeaderDictionary request); + [Obsolete("Use ValidateAuthenticationAsync")] + public virtual void ValidateAuthentication(IHeaderDictionary request) { } /// public abstract Task ValidateAuthenticationAsync(IHeaderDictionary request); diff --git a/OpenAI-DotNet-Proxy/Proxy/EndpointRouteBuilder.cs b/OpenAI-DotNet-Proxy/Proxy/EndpointRouteBuilder.cs index 85736d85..1f44a7b5 100644 --- a/OpenAI-DotNet-Proxy/Proxy/EndpointRouteBuilder.cs +++ b/OpenAI-DotNet-Proxy/Proxy/EndpointRouteBuilder.cs @@ -57,9 +57,10 @@ async Task HandleRequest(HttpContext httpContext, string endpoint) { try { +#pragma warning disable CS0618 // Type or member is obsolete // ReSharper disable once MethodHasAsyncOverload - // just in case either method is implemented we call it twice. authenticationFilter.ValidateAuthentication(httpContext.Request.Headers); +#pragma warning restore CS0618 // Type or member is obsolete await authenticationFilter.ValidateAuthenticationAsync(httpContext.Request.Headers); var method = new HttpMethod(httpContext.Request.Method); @@ -80,21 +81,13 @@ async Task HandleRequest(HttpContext httpContext, string endpoint) foreach (var (key, value) in proxyResponse.Headers) { - if (excludedHeaders.Contains(key)) - { - continue; - } - + if (excludedHeaders.Contains(key)) { continue; } httpContext.Response.Headers[key] = value.ToArray(); } foreach (var (key, value) in proxyResponse.Content.Headers) { - if (excludedHeaders.Contains(key)) - { - continue; - } - + if (excludedHeaders.Contains(key)) { continue; } httpContext.Response.Headers[key] = value.ToArray(); } diff --git a/OpenAI-DotNet-Proxy/Proxy/IAuthenticationFilter.cs b/OpenAI-DotNet-Proxy/Proxy/IAuthenticationFilter.cs index fc1bd624..1d54ce5f 100644 --- a/OpenAI-DotNet-Proxy/Proxy/IAuthenticationFilter.cs +++ b/OpenAI-DotNet-Proxy/Proxy/IAuthenticationFilter.cs @@ -1,6 +1,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using Microsoft.AspNetCore.Http; +using System; using System.Security.Authentication; using System.Threading.Tasks; @@ -11,12 +12,7 @@ namespace OpenAI.Proxy /// public interface IAuthenticationFilter { - /// - /// Checks the headers for your user issued token. - /// If it's not valid, then throw . - /// - /// - /// + [Obsolete("Use ValidateAuthenticationAsync")] void ValidateAuthentication(IHeaderDictionary request); /// diff --git a/OpenAI-DotNet-Proxy/Readme.md b/OpenAI-DotNet-Proxy/Readme.md index 40c45c1c..1485d04f 100644 --- a/OpenAI-DotNet-Proxy/Readme.md +++ b/OpenAI-DotNet-Proxy/Readme.md @@ -49,6 +49,7 @@ In this example, we demonstrate how to set up and use `OpenAIProxy` in a new ASP 1. Create a new [ASP.NET Core minimal web API](https://learn.microsoft.com/en-us/aspnet/core/tutorials/min-web-api?view=aspnetcore-6.0) project. 2. Add the OpenAI-DotNet nuget package to your project. - Powershell install: `Install-Package OpenAI-DotNet-Proxy` + - Dotnet install: `dotnet add package OpenAI-DotNet-Proxy` - Manually editing .csproj: `` 3. Create a new class that inherits from `AbstractAuthenticationFilter` and override the `ValidateAuthentication` method. This will implement the `IAuthenticationFilter` that you will use to check user session token against your internal server. 4. In `Program.cs`, create a new proxy web application by calling `OpenAIProxy.CreateWebApplication` method, passing your custom `AuthenticationFilter` as a type argument. @@ -59,19 +60,9 @@ public partial class Program { private class AuthenticationFilter : AbstractAuthenticationFilter { - public override void ValidateAuthentication(IHeaderDictionary request) - { - // You will need to implement your own class to properly test - // custom issued tokens you've setup for your end users. - if (!request.Authorization.ToString().Contains(TestUserToken)) - { - throw new AuthenticationException("User is not authorized"); - } - } - public override async Task ValidateAuthenticationAsync(IHeaderDictionary request) { - await Task.CompletedTask; // remote resource call + await Task.CompletedTask; // remote resource call to verify token // You will need to implement your own class to properly test // custom issued tokens you've setup for your end users. diff --git a/OpenAI-DotNet-Tests-Proxy/Program.cs b/OpenAI-DotNet-Tests-Proxy/Program.cs index ed20e322..06fd684e 100644 --- a/OpenAI-DotNet-Tests-Proxy/Program.cs +++ b/OpenAI-DotNet-Tests-Proxy/Program.cs @@ -17,19 +17,10 @@ public partial class Program private class AuthenticationFilter : AbstractAuthenticationFilter { - public override void ValidateAuthentication(IHeaderDictionary request) - { - // You will need to implement your own class to properly test - // custom issued tokens you've setup for your end users. - if (!request.Authorization.ToString().Contains(TestUserToken)) - { - throw new AuthenticationException("User is not authorized"); - } - } - + /// public override async Task ValidateAuthenticationAsync(IHeaderDictionary request) { - await Task.CompletedTask; // remote resource call + await Task.CompletedTask; // remote resource call to verify token // You will need to implement your own class to properly test // custom issued tokens you've setup for your end users. diff --git a/OpenAI-DotNet-Tests/TestFixture_00_01_Authentication.cs b/OpenAI-DotNet-Tests/TestFixture_00_01_Authentication.cs index 7a889744..9537d2ce 100644 --- a/OpenAI-DotNet-Tests/TestFixture_00_01_Authentication.cs +++ b/OpenAI-DotNet-Tests/TestFixture_00_01_Authentication.cs @@ -168,6 +168,7 @@ public void Test_11_AzureConfigurationSettings() var auth = new OpenAIAuthentication("testKeyAaBbCcDd"); var settings = new OpenAIClientSettings(resourceName: "test-resource", deploymentId: "deployment-id-test"); var api = new OpenAIClient(auth, settings); + Console.WriteLine(api.OpenAIClientSettings.DeploymentId); Console.WriteLine(api.OpenAIClientSettings.BaseRequest); Console.WriteLine(api.OpenAIClientSettings.BaseRequestUrlFormat); } diff --git a/OpenAI-DotNet-Tests/TestFixture_03_Threads.cs b/OpenAI-DotNet-Tests/TestFixture_03_Threads.cs index f1eedc0d..6bef392d 100644 --- a/OpenAI-DotNet-Tests/TestFixture_03_Threads.cs +++ b/OpenAI-DotNet-Tests/TestFixture_03_Threads.cs @@ -667,5 +667,85 @@ public async Task Test_04_04_CreateThreadAndRun_SubmitToolOutput() } } } + + [Test] + public async Task Test_05_01_CreateThreadAndRun_StructuredOutputs_Streaming() + { + Assert.NotNull(OpenAIClient.ThreadsEndpoint); + var mathSchema = new JsonSchema("math_response", @" +{ + ""type"": ""object"", + ""properties"": { + ""steps"": { + ""type"": ""array"", + ""items"": { + ""type"": ""object"", + ""properties"": { + ""explanation"": { + ""type"": ""string"" + }, + ""output"": { + ""type"": ""string"" + } + }, + ""required"": [ + ""explanation"", + ""output"" + ], + ""additionalProperties"": false + } + }, + ""final_answer"": { + ""type"": ""string"" + } + }, + ""required"": [ + ""steps"", + ""final_answer"" + ], + ""additionalProperties"": false +}"); + var assistant = await OpenAIClient.AssistantsEndpoint.CreateAssistantAsync( + new CreateAssistantRequest( + name: "Math Tutor", + instructions: "You are a helpful math tutor. Guide the user through the solution step by step.", + model: "gpt-4o-2024-08-06", + jsonSchema: mathSchema)); + Assert.NotNull(assistant); + ThreadResponse thread = null; + + try + { + var run = await assistant.CreateThreadAndRunAsync("how can I solve 8x + 7 = -23", + async @event => + { + Console.WriteLine(@event.ToJsonString()); + await Task.CompletedTask; + }); + Assert.IsNotNull(run); + thread = await run.GetThreadAsync(); + run = await run.WaitForStatusChangeAsync(); + Assert.IsNotNull(run); + Assert.IsTrue(run.Status == RunStatus.Completed); + Console.WriteLine($"Created thread and run: {run.ThreadId} -> {run.Id} -> {run.CreatedAt}"); + Assert.NotNull(thread); + var messages = await thread.ListMessagesAsync(); + + foreach (var response in messages.Items) + { + Console.WriteLine($"{response.Role}: {response.PrintContent()}"); + } + } + finally + { + await assistant.DeleteAsync(deleteToolResources: thread == null); + + if (thread != null) + { + var isDeleted = await thread.DeleteAsync(deleteToolResources: true); + Assert.IsTrue(isDeleted); + } + } + } } } diff --git a/OpenAI-DotNet-Tests/TestFixture_04_Chat.cs b/OpenAI-DotNet-Tests/TestFixture_04_Chat.cs index e1cff903..6e99738c 100644 --- a/OpenAI-DotNet-Tests/TestFixture_04_Chat.cs +++ b/OpenAI-DotNet-Tests/TestFixture_04_Chat.cs @@ -301,7 +301,7 @@ public async Task Test_02_03_ChatCompletion_Multiple_Tools_Streaming() }; var tools = Tool.GetAllAvailableTools(false, forceUpdate: true, clearCache: true); - var chatRequest = new ChatRequest(messages, model: Model.GPT4o, tools: tools, toolChoice: "auto"); + var chatRequest = new ChatRequest(messages, model: Model.GPT4o, tools: tools, toolChoice: "auto", parallelToolCalls: true); var response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => { Assert.IsNotNull(partialResponse); @@ -526,5 +526,134 @@ public async Task Test_05_02_GetChat_Enumerable_TestToolCalls_Streaming() } } } + + [Test] + public async Task Test_06_01_GetChat_JsonSchema() + { + Assert.IsNotNull(OpenAIClient.ChatEndpoint); + + var messages = new List + { + new(Role.System, "You are a helpful math tutor. Guide the user through the solution step by step."), + new(Role.User, "how can I solve 8x + 7 = -23") + }; + + var mathSchema = new JsonSchema("math_response", @" +{ + ""type"": ""object"", + ""properties"": { + ""steps"": { + ""type"": ""array"", + ""items"": { + ""type"": ""object"", + ""properties"": { + ""explanation"": { + ""type"": ""string"" + }, + ""output"": { + ""type"": ""string"" + } + }, + ""required"": [ + ""explanation"", + ""output"" + ], + ""additionalProperties"": false + } + }, + ""final_answer"": { + ""type"": ""string"" + } + }, + ""required"": [ + ""steps"", + ""final_answer"" + ], + ""additionalProperties"": false +}"); + var chatRequest = new ChatRequest(messages, model: new("gpt-4o-2024-08-06"), jsonSchema: mathSchema); + var response = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); + Assert.IsNotNull(response); + Assert.IsNotNull(response.Choices); + Assert.IsNotEmpty(response.Choices); + + foreach (var choice in response.Choices) + { + Console.WriteLine($"[{choice.Index}] {choice.Message.Role}: {choice} | Finish Reason: {choice.FinishReason}"); + } + + response.GetUsage(); + } + + [Test] + public async Task Test_06_01_GetChat_JsonSchema_Streaming() + { + Assert.IsNotNull(OpenAIClient.ChatEndpoint); + + var messages = new List + { + new(Role.System, "You are a helpful math tutor. Guide the user through the solution step by step."), + new(Role.User, "how can I solve 8x + 7 = -23") + }; + + var mathSchema = new JsonSchema("math_response", @" +{ + ""type"": ""object"", + ""properties"": { + ""steps"": { + ""type"": ""array"", + ""items"": { + ""type"": ""object"", + ""properties"": { + ""explanation"": { + ""type"": ""string"" + }, + ""output"": { + ""type"": ""string"" + } + }, + ""required"": [ + ""explanation"", + ""output"" + ], + ""additionalProperties"": false + } + }, + ""final_answer"": { + ""type"": ""string"" + } + }, + ""required"": [ + ""steps"", + ""final_answer"" + ], + ""additionalProperties"": false +}"); + var chatRequest = new ChatRequest(messages, model: "gpt-4o-2024-08-06", jsonSchema: mathSchema); + var cumulativeDelta = string.Empty; + var response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => + { + Assert.IsNotNull(partialResponse); + if (partialResponse.Usage != null) { return; } + Assert.NotNull(partialResponse.Choices); + Assert.NotZero(partialResponse.Choices.Count); + + foreach (var choice in partialResponse.Choices.Where(choice => choice.Delta?.Content != null)) + { + cumulativeDelta += choice.Delta.Content; + } + }, true); + Assert.IsNotNull(response); + Assert.IsNotNull(response.Choices); + var choice = response.FirstChoice; + Assert.IsNotNull(choice); + Assert.IsNotNull(choice.Message); + Assert.IsFalse(string.IsNullOrEmpty(choice.ToString())); + Console.WriteLine($"[{choice.Index}] {choice.Message.Role}: {choice} | Finish Reason: {choice.FinishReason}"); + Assert.IsTrue(choice.Message.Role == Role.Assistant); + Assert.IsTrue(choice.Message.Content!.Equals(cumulativeDelta)); + Console.WriteLine(response.ToString()); + response.GetUsage(); + } } } diff --git a/OpenAI-DotNet/Assistants/AssistantExtensions.cs b/OpenAI-DotNet/Assistants/AssistantExtensions.cs index f83dcb5e..07c55e2e 100644 --- a/OpenAI-DotNet/Assistants/AssistantExtensions.cs +++ b/OpenAI-DotNet/Assistants/AssistantExtensions.cs @@ -80,7 +80,21 @@ public static async Task CreateThreadAndRunAsync(this AssistantResp /// Optional, . /// . public static async Task CreateThreadAndRunAsync(this AssistantResponse assistant, CreateThreadRequest request = null, Func streamEventHandler = null, CancellationToken cancellationToken = default) - => await assistant.Client.ThreadsEndpoint.CreateThreadAndRunAsync(new CreateThreadAndRunRequest(assistant.Id, createThreadRequest: request), streamEventHandler, cancellationToken).ConfigureAwait(false); + { + var threadRunRequest = new CreateThreadAndRunRequest( + assistant.Id, + assistant.Model, + assistant.Instructions, + assistant.Tools, + assistant.ToolResources, + assistant.Metadata, + assistant.Temperature, + assistant.TopP, + jsonSchema: assistant.ResponseFormatObject?.JsonSchema, + responseFormat: assistant.ResponseFormat, + createThreadRequest: request); + return await assistant.Client.ThreadsEndpoint.CreateThreadAndRunAsync(threadRunRequest, streamEventHandler, cancellationToken).ConfigureAwait(false); + } #region Tools diff --git a/OpenAI-DotNet/Assistants/AssistantResponse.cs b/OpenAI-DotNet/Assistants/AssistantResponse.cs index cefaa2e2..de724fa7 100644 --- a/OpenAI-DotNet/Assistants/AssistantResponse.cs +++ b/OpenAI-DotNet/Assistants/AssistantResponse.cs @@ -1,6 +1,5 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. -using OpenAI.Extensions; using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -126,20 +125,21 @@ public sealed class AssistantResponse : BaseResponse /// /// Specifies the format that the model must output. - /// Setting to enables JSON mode, + /// Setting to or enables JSON mode, /// which guarantees the message the model generates is valid JSON. /// /// - /// Important: When using JSON mode you must still instruct the model to produce JSON yourself via some conversation message, - /// for example via your system message. If you don't do this, the model may generate an unending stream of - /// whitespace until the generation reaches the token limit, which may take a lot of time and give the appearance - /// of a "stuck" request. Also note that the message content may be partial (i.e. cut off) if finish_reason="length", + /// Important: When using JSON mode, you must also instruct the model to produce JSON yourself via a system or user message. + /// Without this, the model may generate an unending stream of whitespace until the generation reaches the token limit, + /// resulting in a long-running and seemingly "stuck" request. Also note that the message content may be partially cut off if finish_reason="length", /// which indicates the generation exceeded max_tokens or the conversation exceeded the max context length. /// [JsonInclude] [JsonPropertyName("response_format")] - [JsonConverter(typeof(ResponseFormatConverter))] - public ChatResponseFormat ResponseFormat { get; private set; } + public ResponseFormatObject ResponseFormatObject { get; private set; } + + [JsonIgnore] + public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; public static implicit operator string(AssistantResponse assistant) => assistant?.Id; diff --git a/OpenAI-DotNet/Assistants/CreateAssistantRequest.cs b/OpenAI-DotNet/Assistants/CreateAssistantRequest.cs index ace6d011..4b1c3c62 100644 --- a/OpenAI-DotNet/Assistants/CreateAssistantRequest.cs +++ b/OpenAI-DotNet/Assistants/CreateAssistantRequest.cs @@ -1,6 +1,5 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. -using OpenAI.Extensions; using System; using System.Collections.Generic; using System.Linq; @@ -56,14 +55,18 @@ public sealed class CreateAssistantRequest /// So 0.1 means only the tokens comprising the top 10% probability mass are considered. /// We generally recommend altering this or temperature but not both. /// + /// + /// The to use for structured JSON outputs.
+ ///
+ /// + /// /// /// Specifies the format that the model must output. /// Setting to enables JSON mode, /// which guarantees the message the model generates is valid JSON.
- /// Important: When using JSON mode you must still instruct the model to produce JSON yourself via some conversation message, - /// for example via your system message. If you don't do this, the model may generate an unending stream of - /// whitespace until the generation reaches the token limit, which may take a lot of time and give the appearance - /// of a "stuck" request. Also note that the message content may be partial (i.e. cut off) if finish_reason="length", + /// Important: When using JSON mode, you must also instruct the model to produce JSON yourself via a system or user message. + /// Without this, the model may generate an unending stream of whitespace until the generation reaches the token limit, + /// resulting in a long-running and seemingly "stuck" request. Also note that the message content may be partially cut off if finish_reason="length", /// which indicates the generation exceeded max_tokens or the conversation exceeded the max context length. /// public CreateAssistantRequest( @@ -77,6 +80,7 @@ public CreateAssistantRequest( IReadOnlyDictionary metadata = null, double? temperature = null, double? topP = null, + JsonSchema jsonSchema = null, ChatResponseFormat? responseFormat = null) : this( string.IsNullOrWhiteSpace(model) ? assistant.Model : model, @@ -88,6 +92,7 @@ public CreateAssistantRequest( metadata ?? assistant.Metadata, temperature ?? assistant.Temperature, topP ?? assistant.TopP, + jsonSchema ?? assistant.ResponseFormatObject?.JsonSchema, responseFormat ?? assistant.ResponseFormat) { } @@ -150,14 +155,18 @@ public CreateAssistantRequest( /// So 0.1 means only the tokens comprising the top 10% probability mass are considered. /// We generally recommend altering this or temperature but not both. /// + /// + /// The to use for structured JSON outputs.
+ ///
+ /// + /// /// /// Specifies the format that the model must output. - /// Setting to enables JSON mode, + /// Setting to or enables JSON mode, /// which guarantees the message the model generates is valid JSON.
- /// Important: When using JSON mode you must still instruct the model to produce JSON yourself via some conversation message, - /// for example via your system message. If you don't do this, the model may generate an unending stream of - /// whitespace until the generation reaches the token limit, which may take a lot of time and give the appearance - /// of a "stuck" request. Also note that the message content may be partial (i.e. cut off) if finish_reason="length", + /// Important: When using JSON mode, you must also instruct the model to produce JSON yourself via a system or user message. + /// Without this, the model may generate an unending stream of whitespace until the generation reaches the token limit, + /// resulting in a long-running and seemingly "stuck" request. Also note that the message content may be partially cut off if finish_reason="length", /// which indicates the generation exceeded max_tokens or the conversation exceeded the max context length. /// public CreateAssistantRequest( @@ -170,7 +179,8 @@ public CreateAssistantRequest( IReadOnlyDictionary metadata = null, double? temperature = null, double? topP = null, - ChatResponseFormat responseFormat = ChatResponseFormat.Auto) + JsonSchema jsonSchema = null, + ChatResponseFormat responseFormat = ChatResponseFormat.Text) { Model = string.IsNullOrWhiteSpace(model) ? Models.Model.GPT4o : model; Name = name; @@ -181,7 +191,15 @@ public CreateAssistantRequest( Metadata = metadata; Temperature = temperature; TopP = topP; - ResponseFormat = responseFormat; + + if (jsonSchema != null) + { + ResponseFormatObject = jsonSchema; + } + else + { + ResponseFormatObject = responseFormat; + } } /// @@ -247,20 +265,21 @@ public CreateAssistantRequest( /// /// Specifies the format that the model must output. - /// Setting to enables JSON mode, + /// Setting to or enables JSON mode, /// which guarantees the message the model generates is valid JSON. /// /// - /// Important: When using JSON mode you must still instruct the model to produce JSON yourself via some conversation message, - /// for example via your system message. If you don't do this, the model may generate an unending stream of - /// whitespace until the generation reaches the token limit, which may take a lot of time and give the appearance - /// of a "stuck" request. Also note that the message content may be partial (i.e. cut off) if finish_reason="length", + /// Important: When using JSON mode, you must also instruct the model to produce JSON yourself via a system or user message. + /// Without this, the model may generate an unending stream of whitespace until the generation reaches the token limit, + /// resulting in a long-running and seemingly "stuck" request. Also note that the message content may be partially cut off if finish_reason="length", /// which indicates the generation exceeded max_tokens or the conversation exceeded the max context length. /// [JsonPropertyName("response_format")] - [JsonConverter(typeof(ResponseFormatConverter))] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public ChatResponseFormat ResponseFormat { get; } + public ResponseFormatObject ResponseFormatObject { get; } + + [JsonIgnore] + public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; /// /// Set of 16 key-value pairs that can be attached to an object. diff --git a/OpenAI-DotNet/Audio/AudioEndpoint.cs b/OpenAI-DotNet/Audio/AudioEndpoint.cs index 39798c19..be398760 100644 --- a/OpenAI-DotNet/Audio/AudioEndpoint.cs +++ b/OpenAI-DotNet/Audio/AudioEndpoint.cs @@ -23,6 +23,8 @@ public AudioEndpoint(OpenAIClient client) : base(client) { } /// protected override string Root => "audio"; + protected override bool? IsAzureDeployment => true; + /// /// Generates audio from the input text. /// diff --git a/OpenAI-DotNet/Authentication/OpenAIClientSettings.cs b/OpenAI-DotNet/Authentication/OpenAIClientSettings.cs index 9aabc101..065e88c4 100644 --- a/OpenAI-DotNet/Authentication/OpenAIClientSettings.cs +++ b/OpenAI-DotNet/Authentication/OpenAIClientSettings.cs @@ -14,7 +14,7 @@ public sealed class OpenAIClientSettings internal const string OpenAIDomain = "api.openai.com"; internal const string DefaultOpenAIApiVersion = "v1"; internal const string AzureOpenAIDomain = "openai.azure.com"; - internal const string DefaultAzureApiVersion = "2022-12-01"; + internal const string DefaultAzureApiVersion = "2023-05-01"; /// /// Creates a new instance of for use with OpenAI. @@ -97,7 +97,7 @@ public OpenAIClientSettings(string resourceName, string deploymentId, string api ResourceName = resourceName; DeploymentId = deploymentId; ApiVersion = apiVersion; - BaseRequest = $"/openai/deployments/{DeploymentId}/"; + BaseRequest = "/openai/"; BaseRequestUrlFormat = $"{Https}{ResourceName}.{AzureOpenAIDomain}{BaseRequest}{{0}}"; defaultQueryParameters.Add("api-version", ApiVersion); UseOAuthAuthentication = useActiveDirectoryAuthentication; @@ -115,7 +115,10 @@ public OpenAIClientSettings(string resourceName, string deploymentId, string api internal bool UseOAuthAuthentication { get; } - public bool IsAzureDeployment => BaseRequestUrlFormat.Contains(AzureOpenAIDomain); + [Obsolete("Use IsAzureOpenAI")] + public bool IsAzureDeployment => IsAzureOpenAI; + + public bool IsAzureOpenAI => BaseRequestUrlFormat.Contains(AzureOpenAIDomain); private readonly Dictionary defaultQueryParameters = new(); diff --git a/OpenAI-DotNet/Chat/ChatEndpoint.cs b/OpenAI-DotNet/Chat/ChatEndpoint.cs index 85a47d2a..ffcde2a8 100644 --- a/OpenAI-DotNet/Chat/ChatEndpoint.cs +++ b/OpenAI-DotNet/Chat/ChatEndpoint.cs @@ -26,6 +26,8 @@ public ChatEndpoint(OpenAIClient client) : base(client) { } /// protected override string Root => "chat"; + protected override bool? IsAzureDeployment => true; + /// /// Creates a completion for the chat message. /// diff --git a/OpenAI-DotNet/Chat/ChatRequest.cs b/OpenAI-DotNet/Chat/ChatRequest.cs index 20b2df67..291be8fd 100644 --- a/OpenAI-DotNet/Chat/ChatRequest.cs +++ b/OpenAI-DotNet/Chat/ChatRequest.cs @@ -1,6 +1,5 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. -using OpenAI.Extensions; using System; using System.Collections.Generic; using System.Linq; @@ -22,15 +21,17 @@ public ChatRequest( int? maxTokens = null, int? number = null, double? presencePenalty = null, - ChatResponseFormat responseFormat = ChatResponseFormat.Auto, + ChatResponseFormat responseFormat = ChatResponseFormat.Text, int? seed = null, string[] stops = null, double? temperature = null, double? topP = null, int? topLogProbs = null, bool? parallelToolCalls = null, + JsonSchema jsonSchema = null, string user = null) - : this(messages, model, frequencyPenalty, logitBias, maxTokens, number, presencePenalty, responseFormat, seed, stops, temperature, topP, topLogProbs, parallelToolCalls, user) + : this(messages, model, frequencyPenalty, logitBias, maxTokens, number, presencePenalty, + responseFormat, seed, stops, temperature, topP, topLogProbs, parallelToolCalls, jsonSchema, user) { var toolList = tools?.ToList(); @@ -103,7 +104,7 @@ public ChatRequest( /// /// /// An object specifying the format that the model must output. - /// Setting to enables JSON mode, + /// Setting to or enables JSON mode, /// which guarantees the message the model generates is valid JSON. /// /// @@ -128,6 +129,11 @@ public ChatRequest( /// /// Whether to enable parallel function calling during tool use. /// + /// + /// The to use for structured JSON outputs.
+ ///
+ /// + /// /// /// A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. /// @@ -139,13 +145,14 @@ public ChatRequest( int? maxTokens = null, int? number = null, double? presencePenalty = null, - ChatResponseFormat responseFormat = ChatResponseFormat.Auto, + ChatResponseFormat responseFormat = ChatResponseFormat.Text, int? seed = null, string[] stops = null, double? temperature = null, double? topP = null, int? topLogProbs = null, bool? parallelToolCalls = null, + JsonSchema jsonSchema = null, string user = null) { Messages = messages?.ToList(); @@ -161,7 +168,16 @@ public ChatRequest( MaxTokens = maxTokens; Number = number; PresencePenalty = presencePenalty; - ResponseFormat = responseFormat; + + if (jsonSchema != null) + { + ResponseFormatObject = jsonSchema; + } + else + { + ResponseFormatObject = responseFormat; + } + Seed = seed; Stops = stops; Temperature = temperature; @@ -252,20 +268,21 @@ public ChatRequest( /// /// An object specifying the format that the model must output. - /// Setting to enables JSON mode, + /// Setting to or enables JSON mode, /// which guarantees the message the model generates is valid JSON. /// /// - /// Important: When using JSON mode you must still instruct the model to produce JSON yourself via some conversation message, - /// for example via your system message. If you don't do this, the model may generate an unending stream of - /// whitespace until the generation reaches the token limit, which may take a lot of time and give the appearance - /// of a "stuck" request. Also note that the message content may be partial (i.e. cut off) if finish_reason="length", + /// Important: When using JSON mode, you must also instruct the model to produce JSON yourself via a system or user message. + /// Without this, the model may generate an unending stream of whitespace until the generation reaches the token limit, + /// resulting in a long-running and seemingly "stuck" request. Also note that the message content may be partially cut off if finish_reason="length", /// which indicates the generation exceeded max_tokens or the conversation exceeded the max context length. /// + [JsonIgnore] + public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; + [JsonPropertyName("response_format")] - [JsonConverter(typeof(ResponseFormatConverter))] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public ChatResponseFormat ResponseFormat { get; } + public ResponseFormatObject ResponseFormatObject { get; } /// /// This feature is in Beta. If specified, our system will make a best effort to sample deterministically, diff --git a/OpenAI-DotNet/Chat/ChatResponse.cs b/OpenAI-DotNet/Chat/ChatResponse.cs index bdd76176..89a1cc47 100644 --- a/OpenAI-DotNet/Chat/ChatResponse.cs +++ b/OpenAI-DotNet/Chat/ChatResponse.cs @@ -39,6 +39,10 @@ public ChatResponse() { } [JsonPropertyName("model")] public string Model { get; private set; } + [JsonInclude] + [JsonPropertyName("service_tier")] + public string ServiceTier { get; private set; } + /// /// This fingerprint represents the backend configuration that the model runs with. /// Can be used in conjunction with the seed request parameter to understand when @@ -77,7 +81,7 @@ internal void AppendFrom(ChatResponse other) { if (other is null) { return; } - if (!string.IsNullOrWhiteSpace(Id)) + if (!string.IsNullOrWhiteSpace(Id) && !string.IsNullOrWhiteSpace(other.Id)) { if (Id != other.Id) { diff --git a/OpenAI-DotNet/Chat/LogProbs.cs b/OpenAI-DotNet/Chat/LogProbs.cs index 266dcbab..066d6714 100644 --- a/OpenAI-DotNet/Chat/LogProbs.cs +++ b/OpenAI-DotNet/Chat/LogProbs.cs @@ -16,5 +16,12 @@ public sealed class LogProbs [JsonInclude] [JsonPropertyName("content")] public IReadOnlyList Content { get; private set; } + + /// + /// A list of message refusal tokens with log probability information. + /// + [JsonInclude] + [JsonPropertyName("refusal")] + public IReadOnlyList Refusal { get; private set; } } } diff --git a/OpenAI-DotNet/Chat/Message.cs b/OpenAI-DotNet/Chat/Message.cs index c135520b..8fac92e4 100644 --- a/OpenAI-DotNet/Chat/Message.cs +++ b/OpenAI-DotNet/Chat/Message.cs @@ -102,6 +102,13 @@ public Message(string toolCallId, string toolFunctionName, IEnumerable [JsonIgnore(Condition = JsonIgnoreCondition.Never)] public dynamic Content { get; private set; } + /// + /// The refusal message generated by the model. + /// + [JsonInclude] + [JsonPropertyName("refusal")] + public string Refusal { get; private set; } + private List toolCalls; /// diff --git a/OpenAI-DotNet/Common/ChatResponseFormat.cs b/OpenAI-DotNet/Common/ChatResponseFormat.cs index c9294946..4f4bab79 100644 --- a/OpenAI-DotNet/Common/ChatResponseFormat.cs +++ b/OpenAI-DotNet/Common/ChatResponseFormat.cs @@ -10,6 +10,8 @@ public enum ChatResponseFormat [EnumMember(Value = "text")] Text, [EnumMember(Value = "json_object")] - Json + Json, + [EnumMember(Value = "json_schema")] + JsonSchema } } diff --git a/OpenAI-DotNet/Common/Function.cs b/OpenAI-DotNet/Common/Function.cs index 2142987c..4e6a5f7a 100644 --- a/OpenAI-DotNet/Common/Function.cs +++ b/OpenAI-DotNet/Common/Function.cs @@ -36,7 +36,13 @@ public Function() { } /// /// An optional JSON object describing the parameters of the function that the model can generate. /// - public Function(string name, string description = null, JsonNode parameters = null) + /// + /// Whether to enable strict schema adherence when generating the function call. + /// If set to true, the model will follow the exact schema defined in the parameters field. + /// Only a subset of JSON Schema is supported when strict is true. Learn more about Structured Outputs in the function calling guide.
+ /// + /// + public Function(string name, string description = null, JsonNode parameters = null, bool? strict = null) { if (!Regex.IsMatch(name, NameRegex)) { @@ -46,6 +52,7 @@ public Function(string name, string description = null, JsonNode parameters = nu Name = name; Description = description; Parameters = parameters; + Strict = strict; } /// @@ -61,7 +68,14 @@ public Function(string name, string description = null, JsonNode parameters = nu /// /// An optional JSON describing the parameters of the function that the model can generate. /// - public Function(string name, string description, string parameters) + /// + /// Whether to enable strict schema adherence when generating the function call.
+ /// If set to true, the model will follow the exact schema defined in the parameters field.
+ /// Only a subset of JSON Schema is supported when strict is true.
+ /// Learn more about Structured Outputs in the function calling guide.
+ /// + /// + public Function(string name, string description, string parameters, bool? strict = null) { if (!Regex.IsMatch(name, NameRegex)) { @@ -71,15 +85,16 @@ public Function(string name, string description, string parameters) Name = name; Description = description; Parameters = JsonNode.Parse(parameters); + Strict = strict; } - internal Function(string name, JsonNode arguments) + internal Function(string name, JsonNode arguments, bool? strict = null) { Name = name; Arguments = arguments; } - private Function(string name, string description, MethodInfo method, object instance = null) + private Function(string name, string description, MethodInfo method, object instance = null, bool? strict = null) { if (!Regex.IsMatch(name, NameRegex)) { @@ -96,51 +111,52 @@ private Function(string name, string description, MethodInfo method, object inst MethodInfo = method; Parameters = method.GenerateJsonSchema(); Instance = instance; + Strict = strict; functionCache[Name] = this; } - internal static Function GetOrCreateFunction(string name, string description, MethodInfo method, object instance = null) + internal static Function GetOrCreateFunction(string name, string description, MethodInfo method, object instance = null, bool? strict = null) => functionCache.TryGetValue(name, out var function) ? function - : new Function(name, description, method, instance); + : new Function(name, description, method, instance, strict); #region Func<,> Overloads - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); - public static Function FromFunc(string name, Func function, string description = null) - => GetOrCreateFunction(name, description, function.Method, function.Target); + public static Function FromFunc(string name, Func function, string description = null, bool? strict = null) + => GetOrCreateFunction(name, description, function.Method, function.Target, strict); #endregion Func<,> Overloads @@ -214,6 +230,18 @@ public JsonNode Arguments internal set => arguments = value; } + /// + /// Whether to enable strict schema adherence when generating the function call. + /// If set to true, the model will follow the exact schema defined in the parameters field. + /// + /// + /// Only a subset of JSON Schema is supported when strict is true. + /// + [JsonInclude] + [JsonPropertyName("strict")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool? Strict { get; private set; } + /// /// The instance of the object to invoke the method on. /// @@ -389,9 +417,10 @@ private static async Task InvokeInternalAsync(Function function, object[] private (Function function, object[] invokeArgs) ValidateFunctionArguments(CancellationToken cancellationToken = default) { - if (Parameters != null && Parameters.AsObject().Count > 0 && Arguments == null) + var properties = Parameters?["properties"]?.AsObject(); + if (properties?.Count > 0 && Arguments == null) { - throw new ArgumentException($"Function {Name} has parameters but no arguments are set."); + throw new ArgumentException($"Function {Name} has {properties.Count} parameters but no arguments are set!"); } if (!functionCache.TryGetValue(Name, out var function)) diff --git a/OpenAI-DotNet/Common/JsonSchema.cs b/OpenAI-DotNet/Common/JsonSchema.cs new file mode 100644 index 00000000..9cc5a91b --- /dev/null +++ b/OpenAI-DotNet/Common/JsonSchema.cs @@ -0,0 +1,79 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace OpenAI +{ + /// + ///
+ /// + ///
+ public sealed class JsonSchema + { + public JsonSchema() { } + + /// + public JsonSchema(string name, string schema, string description = null, bool strict = true) + : this(name, JsonNode.Parse(schema), description, strict) { } + + /// + /// Constructor. + /// + /// + /// The name of the response format. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. + /// + /// + /// The schema for the response format, described as a JSON Schema object. + /// + /// + /// A description of what the response format is for, used by the model to determine how to respond in the format. + /// + /// + /// Whether to enable strict schema adherence when generating the output. + /// If set to true, the model will always follow the exact schema defined in the schema field. + /// Only a subset of JSON Schema is supported when strict is true. + /// + public JsonSchema(string name, JsonNode schema, string description = null, bool strict = true) + { + Name = name; + Description = description; + Strict = strict; + Schema = schema; + } + + /// + /// The name of the response format. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. + /// + [JsonInclude] + [JsonPropertyName("name")] + public string Name { get; private set; } + + /// + /// A description of what the response format is for, used by the model to determine how to respond in the format. + /// + [JsonInclude] + [JsonPropertyName("description")] + public string Description { get; private set; } + + /// + /// Whether to enable strict schema adherence when generating the output. + /// If set to true, the model will always follow the exact schema defined in the schema field. + /// + /// + /// Only a subset of JSON Schema is supported when strict is true. + /// + [JsonInclude] + [JsonPropertyName("strict")] + public bool Strict { get; private set; } + + /// + /// The schema for the response format, described as a JSON Schema object. + /// + [JsonInclude] + [JsonPropertyName("schema")] + public JsonNode Schema { get; private set; } + + public static implicit operator ResponseFormatObject(JsonSchema jsonSchema) => new(jsonSchema); + } +} diff --git a/OpenAI-DotNet/Common/OpenAIBaseEndpoint.cs b/OpenAI-DotNet/Common/OpenAIBaseEndpoint.cs index e3a7482e..9b536dd1 100644 --- a/OpenAI-DotNet/Common/OpenAIBaseEndpoint.cs +++ b/OpenAI-DotNet/Common/OpenAIBaseEndpoint.cs @@ -13,8 +13,6 @@ public abstract class OpenAIBaseEndpoint // ReSharper disable once InconsistentNaming protected readonly OpenAIClient client; - internal OpenAIClient Client => client; - internal HttpClient HttpClient => client.Client; /// @@ -22,6 +20,16 @@ public abstract class OpenAIBaseEndpoint /// protected abstract string Root { get; } + /// + /// Indicates if the endpoint has an Azure Deployment. + /// + /// + /// If the endpoint is an Azure deployment, is true. + /// If it is not an Azure deployment, is false. + /// If it is not an Azure supported Endpoint, is null. + /// + protected virtual bool? IsAzureDeployment => null; + /// /// Gets the full formatted url for the API endpoint. /// @@ -29,7 +37,18 @@ public abstract class OpenAIBaseEndpoint /// Optional, parameters to add to the endpoint. protected string GetUrl(string endpoint = "", Dictionary queryParameters = null) { - var result = string.Format(client.OpenAIClientSettings.BaseRequestUrlFormat, $"{Root}{endpoint}"); + string route; + + if (client.OpenAIClientSettings.IsAzureOpenAI && IsAzureDeployment == true) + { + route = $"{Root}deployments/{client.OpenAIClientSettings.DeploymentId}/{endpoint}"; + } + else + { + route = $"{Root}{endpoint}"; + } + + var result = string.Format(client.OpenAIClientSettings.BaseRequestUrlFormat, route); foreach (var defaultQueryParameter in client.OpenAIClientSettings.DefaultQueryParameters) { @@ -39,7 +58,7 @@ protected string GetUrl(string endpoint = "", Dictionary queryPa if (queryParameters is { Count: not 0 }) { - result += $"?{string.Join("&", queryParameters.Select(parameter => $"{parameter.Key}={parameter.Value}"))}"; + result += $"?{string.Join('&', queryParameters.Select(parameter => $"{parameter.Key}={parameter.Value}"))}"; } return result; diff --git a/OpenAI-DotNet/Common/ResponseFormatObject.cs b/OpenAI-DotNet/Common/ResponseFormatObject.cs new file mode 100644 index 00000000..32aac488 --- /dev/null +++ b/OpenAI-DotNet/Common/ResponseFormatObject.cs @@ -0,0 +1,41 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using OpenAI.Extensions; +using System.Text.Json.Serialization; + +namespace OpenAI +{ + public sealed class ResponseFormatObject + { + public ResponseFormatObject() { } + + public ResponseFormatObject(ChatResponseFormat type) + { + if (type == ChatResponseFormat.JsonSchema) + { + throw new System.ArgumentException("Use the constructor overload that accepts a JsonSchema object for ChatResponseFormat.JsonSchema.", nameof(type)); + } + Type = type; + } + + public ResponseFormatObject(JsonSchema schema) + { + Type = ChatResponseFormat.JsonSchema; + JsonSchema = schema; + } + + [JsonInclude] + [JsonPropertyName("type")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ChatResponseFormat Type { get; private set; } + + [JsonInclude] + [JsonPropertyName("json_schema")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonSchema JsonSchema { get; private set; } + + public static implicit operator ResponseFormatObject(ChatResponseFormat type) => new(type); + + public static implicit operator ChatResponseFormat(ResponseFormatObject format) => format.Type; + } +} diff --git a/OpenAI-DotNet/Common/Tool.cs b/OpenAI-DotNet/Common/Tool.cs index 8bfa3094..cb969e8b 100644 --- a/OpenAI-DotNet/Common/Tool.cs +++ b/OpenAI-DotNet/Common/Tool.cs @@ -24,9 +24,9 @@ public Tool(Function function) Type = nameof(function); } - public Tool(string toolCallId, string functionName, JsonNode functionArguments) + public Tool(string toolCallId, string functionName, JsonNode functionArguments, bool? strict = null) { - Function = new Function(functionName, arguments: functionArguments); + Function = new Function(functionName, arguments: functionArguments, strict); Type = "function"; Id = toolCallId; } @@ -210,7 +210,7 @@ where method.IsStatic where functionAttribute != null let name = GetFunctionName(type, method) let description = functionAttribute.Description - select Function.GetOrCreateFunction(name, description, method) + select Function.GetOrCreateFunction(name, description, method, strict: true) into function select new Tool(function)); @@ -343,7 +343,7 @@ private static Tool GetOrCreateToolInternal(Type type, MethodInfo method, string return tool; } - tool = new Tool(Function.GetOrCreateFunction(functionName, description ?? string.Empty, method, instance)); + tool = new Tool(Function.GetOrCreateFunction(functionName, description, method, instance, strict: true)); toolCache.Add(tool); return tool; } diff --git a/OpenAI-DotNet/Embeddings/EmbeddingsEndpoint.cs b/OpenAI-DotNet/Embeddings/EmbeddingsEndpoint.cs index a6d1db7d..df4761ac 100644 --- a/OpenAI-DotNet/Embeddings/EmbeddingsEndpoint.cs +++ b/OpenAI-DotNet/Embeddings/EmbeddingsEndpoint.cs @@ -20,6 +20,8 @@ public EmbeddingsEndpoint(OpenAIClient client) : base(client) { } /// protected override string Root => "embeddings"; + protected override bool? IsAzureDeployment => true; + /// /// Creates an embedding vector representing the input text. /// diff --git a/OpenAI-DotNet/Extensions/ResponseFormatConverter.cs b/OpenAI-DotNet/Extensions/ResponseFormatConverter.cs deleted file mode 100644 index b0d528d9..00000000 --- a/OpenAI-DotNet/Extensions/ResponseFormatConverter.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace OpenAI.Extensions -{ - internal sealed class ResponseFormatConverter : JsonConverter - { - private class ResponseFormatObject - { - public ResponseFormatObject(ChatResponseFormat type) => Type = type; - - [JsonInclude] - [JsonPropertyName("type")] - [JsonConverter(typeof(JsonStringEnumConverter))] - public ChatResponseFormat Type { get; private set; } - - public static implicit operator ResponseFormatObject(ChatResponseFormat type) => new(type); - - public static implicit operator ChatResponseFormat(ResponseFormatObject format) => format.Type; - } - - public override ChatResponseFormat Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - try - { - if (reader.TokenType is JsonTokenType.Null or JsonTokenType.String) - { - return ChatResponseFormat.Auto; - } - - return JsonSerializer.Deserialize(ref reader, options); - } - catch (Exception e) - { - throw new Exception($"Error reading {typeof(ChatResponseFormat)} from JSON.", e); - } - } - - public override void Write(Utf8JsonWriter writer, ChatResponseFormat value, JsonSerializerOptions options) - { - const string type = nameof(type); - const string text = nameof(text); - // ReSharper disable once InconsistentNaming - const string json_object = nameof(json_object); - - switch (value) - { - case ChatResponseFormat.Auto: - writer.WriteNullValue(); - break; - case ChatResponseFormat.Text: - writer.WriteStartObject(); - writer.WriteString(type, text); - writer.WriteEndObject(); - break; - case ChatResponseFormat.Json: - writer.WriteStartObject(); - writer.WriteString(type, json_object); - writer.WriteEndObject(); - break; - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } -} diff --git a/OpenAI-DotNet/Extensions/TypeExtensions.cs b/OpenAI-DotNet/Extensions/TypeExtensions.cs index 6497153a..97e7a5ed 100644 --- a/OpenAI-DotNet/Extensions/TypeExtensions.cs +++ b/OpenAI-DotNet/Extensions/TypeExtensions.cs @@ -12,15 +12,10 @@ namespace OpenAI.Extensions { internal static class TypeExtensions { - public static JsonObject GenerateJsonSchema(this MethodInfo methodInfo) + public static JsonObject GenerateJsonSchema(this MethodInfo methodInfo, JsonSerializerOptions options = null) { var parameters = methodInfo.GetParameters(); - if (parameters.Length == 0) - { - return null; - } - var schema = new JsonObject { ["type"] = "object", @@ -30,10 +25,7 @@ public static JsonObject GenerateJsonSchema(this MethodInfo methodInfo) foreach (var parameter in parameters) { - if (parameter.ParameterType == typeof(CancellationToken)) - { - continue; - } + if (parameter.ParameterType == typeof(CancellationToken)) { continue; } if (string.IsNullOrWhiteSpace(parameter.Name)) { @@ -45,7 +37,7 @@ public static JsonObject GenerateJsonSchema(this MethodInfo methodInfo) requiredParameters.Add(parameter.Name); } - schema["properties"]![parameter.Name] = GenerateJsonSchema(parameter.ParameterType, schema); + schema["properties"]![parameter.Name] = GenerateJsonSchema(parameter.ParameterType, schema, options); var functionParameterAttribute = parameter.GetCustomAttribute(); @@ -60,11 +52,13 @@ public static JsonObject GenerateJsonSchema(this MethodInfo methodInfo) schema["required"] = requiredParameters; } + schema["additionalProperties"] = false; return schema; } - public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchema) + public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchema, JsonSerializerOptions options = null) { + options ??= OpenAIClient.JsonSerializationOptions; var schema = new JsonObject(); if (!type.IsPrimitive && @@ -72,7 +66,7 @@ public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchem type != typeof(DateTime) && type != typeof(DateTimeOffset) && rootSchema["definitions"] != null && - rootSchema["definitions"].AsObject().ContainsKey(type.FullName)) + rootSchema["definitions"].AsObject().ContainsKey(type.FullName!)) { return new JsonObject { ["$ref"] = $"#/definitions/{type.FullName}" }; } @@ -119,7 +113,7 @@ public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchem foreach (var value in Enum.GetValues(type)) { - schema["enum"].AsArray().Add(JsonNode.Parse(JsonSerializer.Serialize(value, OpenAIClient.JsonSerializationOptions))); + schema["enum"].AsArray().Add(JsonNode.Parse(JsonSerializer.Serialize(value, options))); } } else if (type.IsArray || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>))) @@ -128,7 +122,7 @@ public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchem var elementType = type.GetElementType() ?? type.GetGenericArguments()[0]; if (rootSchema["definitions"] != null && - rootSchema["definitions"].AsObject().ContainsKey(elementType.FullName)) + rootSchema["definitions"].AsObject().ContainsKey(elementType.FullName!)) { schema["items"] = new JsonObject { ["$ref"] = $"#/definitions/{elementType.FullName}" }; } @@ -141,7 +135,7 @@ public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchem { schema["type"] = "object"; rootSchema["definitions"] ??= new JsonObject(); - rootSchema["definitions"][type.FullName] = new JsonObject(); + rootSchema["definitions"][type.FullName!] = new JsonObject(); var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); @@ -163,7 +157,7 @@ public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchem JsonObject propertyInfo; if (rootSchema["definitions"] != null && - rootSchema["definitions"].AsObject().ContainsKey(memberType.FullName)) + rootSchema["definitions"].AsObject().ContainsKey(memberType.FullName!)) { propertyInfo = new JsonObject { ["$ref"] = $"#/definitions/{memberType.FullName}" }; } @@ -186,7 +180,7 @@ public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchem if (functionPropertyAttribute.DefaultValue != null) { - defaultValue = JsonNode.Parse(JsonSerializer.Serialize(functionPropertyAttribute.DefaultValue, OpenAIClient.JsonSerializationOptions)); + defaultValue = JsonNode.Parse(JsonSerializer.Serialize(functionPropertyAttribute.DefaultValue, options)); propertyInfo["default"] = defaultValue; } @@ -196,7 +190,7 @@ public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchem foreach (var value in functionPropertyAttribute.PossibleValues) { - var @enum = JsonNode.Parse(JsonSerializer.Serialize(value, OpenAIClient.JsonSerializationOptions)); + var @enum = JsonNode.Parse(JsonSerializer.Serialize(value, options)); if (defaultValue == null) { @@ -213,7 +207,7 @@ public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchem if (defaultValue != null && !enums.Contains(defaultValue)) { - enums.Add(JsonNode.Parse(defaultValue.ToJsonString(OpenAIClient.JsonSerializationOptions))); + enums.Add(JsonNode.Parse(defaultValue.ToJsonString(options))); } propertyInfo["enum"] = enums; @@ -250,6 +244,7 @@ public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchem schema["required"] = memberProperties; } + schema["additionalProperties"] = false; rootSchema["definitions"] ??= new JsonObject(); rootSchema["definitions"][type.FullName] = schema; return new JsonObject { ["$ref"] = $"#/definitions/{type.FullName}" }; diff --git a/OpenAI-DotNet/Images/ImagesEndpoint.cs b/OpenAI-DotNet/Images/ImagesEndpoint.cs index 54903038..079bd705 100644 --- a/OpenAI-DotNet/Images/ImagesEndpoint.cs +++ b/OpenAI-DotNet/Images/ImagesEndpoint.cs @@ -22,6 +22,8 @@ internal ImagesEndpoint(OpenAIClient client) : base(client) { } /// protected override string Root => "images"; + protected override bool? IsAzureDeployment => true; + /// /// Creates an image given a prompt. /// diff --git a/OpenAI-DotNet/OpenAI-DotNet.csproj b/OpenAI-DotNet/OpenAI-DotNet.csproj index b6ebd04c..6968afe0 100644 --- a/OpenAI-DotNet/OpenAI-DotNet.csproj +++ b/OpenAI-DotNet/OpenAI-DotNet.csproj @@ -29,8 +29,15 @@ More context [on Roger Pincombe's blog](https://rogerpincombe.com/openai-dotnet- OpenAI-DotNet.pfx true true - 8.1.2 + 8.2.0 +Version 8.2.0 +- Added structured output support +- Added support for Azure OpenAI assistants +- Fixed Azure OpenAI Id parsing for events +- Fixed Assistant.CreateThreadAndRunAsync to properly copy assistant parameters +- Removed stream parameter from CreateThreadAndRunRequest and CreateRunRequest + - They were overridden by the presence of an IStreamEventHandler anyway Version 8.1.2 - Added constructor overloads to Tool and Function classes to support manually adding tool calls in the conversation history Version 8.1.1 diff --git a/OpenAI-DotNet/Threads/CreateRunRequest.cs b/OpenAI-DotNet/Threads/CreateRunRequest.cs index b6db36a1..8b94ed6d 100644 --- a/OpenAI-DotNet/Threads/CreateRunRequest.cs +++ b/OpenAI-DotNet/Threads/CreateRunRequest.cs @@ -1,6 +1,5 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. -using OpenAI.Extensions; using System; using System.Collections.Generic; using System.Linq; @@ -31,13 +30,13 @@ public CreateRunRequest(string assistantId, CreateRunRequest request) request?.Metadata, request?.Temperature, request?.TopP, - request?.Stream ?? false, request?.MaxPromptTokens, request?.MaxCompletionTokens, request?.TruncationStrategy, request?.ToolChoice as string ?? ((Tool)request?.ToolChoice)?.Function?.Name, request?.ParallelToolCalls, - request?.ResponseFormat ?? ChatResponseFormat.Auto) + request?.ResponseFormatObject?.JsonSchema, + request?.ResponseFormatObject ?? ChatResponseFormat.Text) { } @@ -79,10 +78,6 @@ public CreateRunRequest(string assistantId, CreateRunRequest request) /// So 0.1 means only the tokens comprising the top 10% probability mass are considered. /// We generally recommend altering this or temperature but not both. /// - /// - /// If true, returns a stream of events that happen during the Run as server-sent events, - /// terminating when the Run enters a terminal state with a 'data: [DONE]' message. - /// /// /// The maximum number of prompt tokens that may be used over the course of the run. /// The run will make a best effort to use only the number of prompt tokens specified, @@ -110,14 +105,18 @@ public CreateRunRequest(string assistantId, CreateRunRequest request) /// /// Whether to enable parallel function calling during tool use. /// + /// + /// The to use for structured JSON outputs.
+ ///
+ /// + /// /// /// An object specifying the format that the model must output. - /// Setting to enables JSON mode, + /// Setting to or enables JSON mode, /// which guarantees the message the model generates is valid JSON.
- /// Important: When using JSON mode you must still instruct the model to produce JSON yourself via some conversation message, - /// for example via your system message. If you don't do this, the model may generate an unending stream of - /// whitespace until the generation reaches the token limit, which may take a lot of time and give the appearance - /// of a "stuck" request. Also note that the message content may be partial (i.e. cut off) if finish_reason="length", + /// Important: When using JSON mode, you must also instruct the model to produce JSON yourself via a system or user message. + /// Without this, the model may generate an unending stream of whitespace until the generation reaches the token limit, + /// resulting in a long-running and seemingly "stuck" request. Also note that the message content may be partially cut off if finish_reason="length", /// which indicates the generation exceeded max_tokens or the conversation exceeded the max context length. /// public CreateRunRequest( @@ -130,13 +129,13 @@ public CreateRunRequest( IReadOnlyDictionary metadata = null, double? temperature = null, double? topP = null, - bool stream = false, int? maxPromptTokens = null, int? maxCompletionTokens = null, TruncationStrategy truncationStrategy = null, string toolChoice = null, bool? parallelToolCalls = null, - ChatResponseFormat responseFormat = ChatResponseFormat.Auto) + JsonSchema jsonSchema = null, + ChatResponseFormat responseFormat = ChatResponseFormat.Text) { AssistantId = assistantId; Model = model; @@ -173,12 +172,19 @@ public CreateRunRequest( Metadata = metadata; Temperature = temperature; TopP = topP; - Stream = stream; MaxPromptTokens = maxPromptTokens; MaxCompletionTokens = maxCompletionTokens; TruncationStrategy = truncationStrategy; ParallelToolCalls = parallelToolCalls; - ResponseFormat = responseFormat; + + if (jsonSchema != null) + { + ResponseFormatObject = jsonSchema; + } + else + { + ResponseFormatObject = responseFormat; + } } /// @@ -299,7 +305,7 @@ public CreateRunRequest( /// /// An object specifying the format that the model must output. - /// Setting to enables JSON mode, + /// Setting to or enables JSON mode, /// which guarantees the message the model generates is valid JSON. /// /// @@ -310,8 +316,10 @@ public CreateRunRequest( /// which indicates the generation exceeded max_tokens or the conversation exceeded the max context length. /// [JsonPropertyName("response_format")] - [JsonConverter(typeof(ResponseFormatConverter))] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public ChatResponseFormat ResponseFormat { get; } + public ResponseFormatObject ResponseFormatObject { get; } + + [JsonIgnore] + public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; } } diff --git a/OpenAI-DotNet/Threads/CreateThreadAndRunRequest.cs b/OpenAI-DotNet/Threads/CreateThreadAndRunRequest.cs index 87312dd1..50dd02aa 100644 --- a/OpenAI-DotNet/Threads/CreateThreadAndRunRequest.cs +++ b/OpenAI-DotNet/Threads/CreateThreadAndRunRequest.cs @@ -1,6 +1,5 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. -using OpenAI.Extensions; using System; using System.Collections.Generic; using System.Linq; @@ -27,13 +26,13 @@ public CreateThreadAndRunRequest(string assistantId, CreateThreadAndRunRequest r request?.Metadata, request?.Temperature, request?.TopP, - request?.Stream ?? false, request?.MaxPromptTokens, request?.MaxCompletionTokens, request?.TruncationStrategy, request?.ToolChoice as string ?? ((Tool)request?.ToolChoice)?.Function?.Name, request?.ParallelToolCalls, - request?.ResponseFormat ?? ChatResponseFormat.Auto) + request?.ResponseFormatObject?.JsonSchema, + request?.ResponseFormat ?? ChatResponseFormat.Text) { } @@ -78,10 +77,6 @@ public CreateThreadAndRunRequest(string assistantId, CreateThreadAndRunRequest r /// So 0.1 means only the tokens comprising the top 10% probability mass are considered. /// We generally recommend altering this or temperature but not both. /// - /// - /// If true, returns a stream of events that happen during the Run as server-sent events, - /// terminating when the Run enters a terminal state with a 'data: [DONE]' message. - /// /// /// The maximum number of prompt tokens that may be used over the course of the run. /// The run will make a best effort to use only the number of prompt tokens specified, @@ -109,14 +104,18 @@ public CreateThreadAndRunRequest(string assistantId, CreateThreadAndRunRequest r /// /// Whether to enable parallel function calling during tool use. /// + /// + /// The to use for structured JSON outputs.
+ ///
+ /// + /// /// /// An object specifying the format that the model must output. - /// Setting to enables JSON mode, + /// Setting to or enables JSON mode, /// which guarantees the message the model generates is valid JSON.
- /// Important: When using JSON mode you must still instruct the model to produce JSON yourself via some conversation message, - /// for example via your system message. If you don't do this, the model may generate an unending stream of - /// whitespace until the generation reaches the token limit, which may take a lot of time and give the appearance - /// of a "stuck" request. Also note that the message content may be partial (i.e. cut off) if finish_reason="length", + /// Important: When using JSON mode, you must also instruct the model to produce JSON yourself via a system or user message. + /// Without this, the model may generate an unending stream of whitespace until the generation reaches the token limit, + /// resulting in a long-running and seemingly "stuck" request. Also note that the message content may be partially cut off if finish_reason="length", /// which indicates the generation exceeded max_tokens or the conversation exceeded the max context length. /// /// @@ -131,13 +130,13 @@ public CreateThreadAndRunRequest( IReadOnlyDictionary metadata = null, double? temperature = null, double? topP = null, - bool stream = false, int? maxPromptTokens = null, int? maxCompletionTokens = null, TruncationStrategy truncationStrategy = null, string toolChoice = null, bool? parallelToolCalls = null, - ChatResponseFormat responseFormat = ChatResponseFormat.Auto, + JsonSchema jsonSchema = null, + ChatResponseFormat responseFormat = ChatResponseFormat.Text, CreateThreadRequest createThreadRequest = null) { AssistantId = assistantId; @@ -174,11 +173,19 @@ public CreateThreadAndRunRequest( Metadata = metadata; Temperature = temperature; TopP = topP; - Stream = stream; MaxPromptTokens = maxPromptTokens; MaxCompletionTokens = maxCompletionTokens; TruncationStrategy = truncationStrategy; - ResponseFormat = responseFormat; + + if (jsonSchema != null) + { + ResponseFormatObject = jsonSchema; + } + else + { + ResponseFormatObject = responseFormat; + } + ParallelToolCalls = parallelToolCalls; ThreadRequest = createThreadRequest; } @@ -300,7 +307,7 @@ public CreateThreadAndRunRequest( /// /// An object specifying the format that the model must output. - /// Setting to enables JSON mode, + /// Setting to or enables JSON mode, /// which guarantees the message the model generates is valid JSON. /// /// @@ -311,9 +318,11 @@ public CreateThreadAndRunRequest( /// which indicates the generation exceeded max_tokens or the conversation exceeded the max context length. /// [JsonPropertyName("response_format")] - [JsonConverter(typeof(ResponseFormatConverter))] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public ChatResponseFormat ResponseFormat { get; } + public ResponseFormatObject ResponseFormatObject { get; } + + [JsonIgnore] + public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; /// /// The optional options to use. diff --git a/OpenAI-DotNet/Threads/MessageResponse.cs b/OpenAI-DotNet/Threads/MessageResponse.cs index 0c0600ed..64eb540e 100644 --- a/OpenAI-DotNet/Threads/MessageResponse.cs +++ b/OpenAI-DotNet/Threads/MessageResponse.cs @@ -181,7 +181,7 @@ internal void AppendFrom(MessageResponse other) { if (other == null) { return; } - if (!string.IsNullOrWhiteSpace(Id)) + if (!string.IsNullOrWhiteSpace(Id) && !string.IsNullOrWhiteSpace(other.Id)) { if (Id != other.Id) { diff --git a/OpenAI-DotNet/Threads/RunResponse.cs b/OpenAI-DotNet/Threads/RunResponse.cs index bffb3b93..18509120 100644 --- a/OpenAI-DotNet/Threads/RunResponse.cs +++ b/OpenAI-DotNet/Threads/RunResponse.cs @@ -268,7 +268,7 @@ public IReadOnlyList Tools /// /// Specifies the format that the model must output. - /// Setting to enables JSON mode, + /// Setting to or enables JSON mode, /// which guarantees the message the model generates is valid JSON. /// /// @@ -280,9 +280,11 @@ public IReadOnlyList Tools /// [JsonInclude] [JsonPropertyName("response_format")] - [JsonConverter(typeof(ResponseFormatConverter))] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public ChatResponseFormat ResponseFormat { get; private set; } + public ResponseFormatObject ResponseFormatObject { get; private set; } + + [JsonIgnore] + public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; public static implicit operator string(RunResponse run) => run?.ToString(); @@ -292,7 +294,7 @@ internal void AppendFrom(RunResponse other) { if (other is null) { return; } - if (!string.IsNullOrWhiteSpace(Id)) + if (!string.IsNullOrWhiteSpace(Id) && !string.IsNullOrWhiteSpace(other.Id)) { if (Id != other.Id) { @@ -401,7 +403,10 @@ internal void AppendFrom(RunResponse other) ToolChoice = other.ToolChoice; } - ResponseFormat = other.ResponseFormat; + if (other.ResponseFormatObject != null) + { + ResponseFormatObject = other.ResponseFormatObject; + } } } } diff --git a/OpenAI-DotNet/Threads/RunStepResponse.cs b/OpenAI-DotNet/Threads/RunStepResponse.cs index c71c7574..46733e1f 100644 --- a/OpenAI-DotNet/Threads/RunStepResponse.cs +++ b/OpenAI-DotNet/Threads/RunStepResponse.cs @@ -191,7 +191,7 @@ internal void AppendFrom(RunStepResponse other) { if (other == null) { return; } - if (!string.IsNullOrWhiteSpace(Id)) + if (!string.IsNullOrWhiteSpace(Id) && !string.IsNullOrWhiteSpace(other.Id)) { if (Id != other.Id) { diff --git a/README.md b/README.md index 71d94491..72d8365e 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,18 @@ More context [on Roger Pincombe's blog](https://rogerpincombe.com/openai-dotnet- Install package [`OpenAI-DotNet` from Nuget](https://www.nuget.org/packages/OpenAI-DotNet/). Here's how via command line: -```powershell +powershell: + +```terminal Install-Package OpenAI-DotNet ``` +dotnet: + +```terminal +dotnet add package OpenAI-DotNet +``` + > Looking to [use OpenAI-DotNet in the Unity Game Engine](https://github.com/RageAgainstThePixel/com.openai.unity)? Check out our unity package on OpenUPM: > >[![openupm](https://img.shields.io/npm/v/com.openai.unity?label=openupm®istry_uri=https://package.openupm.com)](https://openupm.com/packages/com.openai.unity/) @@ -50,17 +58,17 @@ Install-Package OpenAI-DotNet - [List Models](#list-models) - [Retrieve Models](#retrieve-model) - [Delete Fine Tuned Model](#delete-fine-tuned-model) -- [Assistants](#assistants) :warning: :construction: +- [Assistants](#assistants) - [List Assistants](#list-assistants) - [Create Assistant](#create-assistant) - [Retrieve Assistant](#retrieve-assistant) - [Modify Assistant](#modify-assistant) - [Delete Assistant](#delete-assistant) - - [Assistant Streaming](#assistant-streaming) :warning: :construction: - - [Threads](#threads) :warning: :construction: + - [Assistant Streaming](#assistant-streaming) + - [Threads](#threads) - [Create Thread](#create-thread) - [Create Thread and Run](#create-thread-and-run) - - [Streaming](#create-thread-and-run-streaming) :warning: :construction: + - [Streaming](#create-thread-and-run-streaming) - [Retrieve Thread](#retrieve-thread) - [Modify Thread](#modify-thread) - [Delete Thread](#delete-thread) @@ -72,10 +80,11 @@ Install-Package OpenAI-DotNet - [Thread Runs](#thread-runs) - [List Runs](#list-thread-runs) - [Create Run](#create-thread-run) - - [Streaming](#create-thread-run-streaming) :warning: :construction: + - [Streaming](#create-thread-run-streaming) - [Retrieve Run](#retrieve-thread-run) - [Modify Run](#modify-thread-run) - [Submit Tool Outputs to Run](#thread-submit-tool-outputs-to-run) + - [Structured Outputs](#thread-structured-outputs) :new: - [List Run Steps](#list-thread-run-steps) - [Retrieve Run Step](#retrieve-thread-run-step) - [Cancel Run](#cancel-thread-run) @@ -100,12 +109,13 @@ Install-Package OpenAI-DotNet - [Streaming](#chat-streaming) - [Tools](#chat-tools) - [Vision](#chat-vision) + - [Json Schema](#chat-json-schema) :new: - [Json Mode](#chat-json-mode) - [Audio](#audio) - [Create Speech](#create-speech) - [Create Transcription](#create-transcription) - [Create Translation](#create-translation) -- [Images](#images) :warning: :construction: +- [Images](#images) - [Create Image](#create-image) - [Edit Image](#edit-image) - [Create Image Variation](#create-image-variation) @@ -305,6 +315,7 @@ In this example, we demonstrate how to set up and use `OpenAIProxy` in a new ASP 1. Create a new [ASP.NET Core minimal web API](https://learn.microsoft.com/en-us/aspnet/core/tutorials/min-web-api?view=aspnetcore-6.0) project. 2. Add the OpenAI-DotNet nuget package to your project. - Powershell install: `Install-Package OpenAI-DotNet-Proxy` + - Dotnet install: `dotnet add package OpenAI-DotNet-Proxy` - Manually editing .csproj: `` 3. Create a new class that inherits from `AbstractAuthenticationFilter` and override the `ValidateAuthentication` method. This will implement the `IAuthenticationFilter` that you will use to check user session token against your internal server. 4. In `Program.cs`, create a new proxy web application by calling `OpenAIProxy.CreateWebApplication` method, passing your custom `AuthenticationFilter` as a type argument. @@ -315,19 +326,9 @@ public partial class Program { private class AuthenticationFilter : AbstractAuthenticationFilter { - public override void ValidateAuthentication(IHeaderDictionary request) - { - // You will need to implement your own class to properly test - // custom issued tokens you've setup for your end users. - if (!request.Authorization.ToString().Contains(TestUserToken)) - { - throw new AuthenticationException("User is not authorized"); - } - } - public override async Task ValidateAuthenticationAsync(IHeaderDictionary request) { - await Task.CompletedTask; // remote resource call + await Task.CompletedTask; // remote resource call to verify token // You will need to implement your own class to properly test // custom issued tokens you've setup for your end users. @@ -808,6 +809,87 @@ foreach (var message in messages.Items.OrderBy(response => response.CreatedAt)) } ``` +##### [Thread Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs) + +Structured Outputs is the evolution of JSON mode. While both ensure valid JSON is produced, only Structured Outputs ensure schema adherence. + +> [!IMPORTANT] +> +> - When using JSON mode, always instruct the model to produce JSON via some message in the conversation, for example via your system message. If you don't include an explicit instruction to generate JSON, the model may generate an unending stream of whitespace and the request may run continually until it reaches the token limit. To help ensure you don't forget, the API will throw an error if the string "JSON" does not appear somewhere in the context. +> - The JSON in the message the model returns may be partial (i.e. cut off) if `finish_reason` is length, which indicates the generation exceeded max_tokens or the conversation exceeded the token limit. To guard against this, check `finish_reason` before parsing the response. + +```csharp +var mathSchema = new JsonSchema("math_response", @" +{ + ""type"": ""object"", + ""properties"": { + ""steps"": { + ""type"": ""array"", + ""items"": { + ""type"": ""object"", + ""properties"": { + ""explanation"": { + ""type"": ""string"" + }, + ""output"": { + ""type"": ""string"" + } + }, + ""required"": [ + ""explanation"", + ""output"" + ], + ""additionalProperties"": false + } + }, + ""final_answer"": { + ""type"": ""string"" + } + }, + ""required"": [ + ""steps"", + ""final_answer"" + ], + ""additionalProperties"": false +}"); +var assistant = await OpenAIClient.AssistantsEndpoint.CreateAssistantAsync( + new CreateAssistantRequest( + name: "Math Tutor", + instructions: "You are a helpful math tutor. Guide the user through the solution step by step.", + model: "gpt-4o-2024-08-06", + jsonSchema: mathSchema)); +ThreadResponse thread = null; + +try +{ + var run = await assistant.CreateThreadAndRunAsync("how can I solve 8x + 7 = -23", + async @event => + { + Console.WriteLine(@event.ToJsonString()); + await Task.CompletedTask; + }); + thread = await run.GetThreadAsync(); + run = await run.WaitForStatusChangeAsync(); + Console.WriteLine($"Created thread and run: {run.ThreadId} -> {run.Id} -> {run.CreatedAt}"); + var messages = await thread.ListMessagesAsync(); + + foreach (var response in messages.Items) + { + Console.WriteLine($"{response.Role}: {response.PrintContent()}"); + } +} +finally +{ + await assistant.DeleteAsync(deleteToolResources: thread == null); + + if (thread != null) + { + var isDeleted = await thread.DeleteAsync(deleteToolResources: true); + Assert.IsTrue(isDeleted); + } +} +``` + ###### [List Thread Run Steps](https://platform.openai.com/docs/api-reference/runs/listRunSteps) Returns a list of run steps belonging to a run. @@ -1179,10 +1261,67 @@ var response = await api.ChatEndpoint.GetCompletionAsync(chatRequest); Console.WriteLine($"{response.FirstChoice.Message.Role}: {response.FirstChoice.Message.Content} | Finish Reason: {response.FirstChoice.FinishDetails}"); ``` -#### [Chat Json Mode](https://platform.openai.com/docs/guides/text-generation/json-mode) +#### [Chat Json Schema](https://platform.openai.com/docs/guides/structured-outputs) -> [!WARNING] -> Beta Feature. API subject to breaking changes. +The evolution of [Json Mode](#chat-json-mode). While both ensure valid JSON is produced, only Structured Outputs ensure schema adherence. + +> [!IMPORTANT] +> +> - When using JSON mode, always instruct the model to produce JSON via some message in the conversation, for example via your system message. If you don't include an explicit instruction to generate JSON, the model may generate an unending stream of whitespace and the request may run continually until it reaches the token limit. To help ensure you don't forget, the API will throw an error if the string "JSON" does not appear somewhere in the context. +> - The JSON in the message the model returns may be partial (i.e. cut off) if `finish_reason` is length, which indicates the generation exceeded max_tokens or the conversation exceeded the token limit. To guard against this, check `finish_reason` before parsing the response. + +```csharp +var messages = new List +{ + new(Role.System, "You are a helpful math tutor. Guide the user through the solution step by step."), + new(Role.User, "how can I solve 8x + 7 = -23") +}; + +var mathSchema = new JsonSchema("math_response", @" +{ + ""type"": ""object"", + ""properties"": { + ""steps"": { + ""type"": ""array"", + ""items"": { + ""type"": ""object"", + ""properties"": { + ""explanation"": { + ""type"": ""string"" + }, + ""output"": { + ""type"": ""string"" + } + }, + ""required"": [ + ""explanation"", + ""output"" + ], + ""additionalProperties"": false + } + }, + ""final_answer"": { + ""type"": ""string"" + } + }, + ""required"": [ + ""steps"", + ""final_answer"" + ], + ""additionalProperties"": false +}"); +var chatRequest = new ChatRequest(messages, model: new("gpt-4o-2024-08-06"), jsonSchema: mathSchema); +var response = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); + +foreach (var choice in response.Choices) +{ + Console.WriteLine($"[{choice.Index}] {choice.Message.Role}: {choice} | Finish Reason: {choice.FinishReason}"); +} + +response.GetUsage(); +``` + +#### [Chat Json Mode](https://platform.openai.com/docs/guides/text-generation/json-mode) > [!IMPORTANT] >