diff --git a/OpenAI-DotNet-Tests/TestFixture_00_01_Authentication.cs b/OpenAI-DotNet-Tests/TestFixture_00_01_Authentication.cs index 9537d2ce..e536da1c 100644 --- a/OpenAI-DotNet-Tests/TestFixture_00_01_Authentication.cs +++ b/OpenAI-DotNet-Tests/TestFixture_00_01_Authentication.cs @@ -168,9 +168,9 @@ 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); + Assert.AreEqual("https://test-resource.openai.azure.com/openai/{0}", api.OpenAIClientSettings.BaseRequestUrlFormat); } [Test] @@ -181,6 +181,7 @@ public void Test_12_CustomDomainConfigurationSettings() var api = new OpenAIClient(auth, settings); Console.WriteLine(api.OpenAIClientSettings.BaseRequest); Console.WriteLine(api.OpenAIClientSettings.BaseRequestUrlFormat); + Assert.AreEqual("https://api.your-custom-domain.com/v1/{0}", api.OpenAIClientSettings.BaseRequestUrlFormat); } [TearDown] diff --git a/OpenAI-DotNet-Tests/TestFixture_00_02_Tools.cs b/OpenAI-DotNet-Tests/TestFixture_00_02_Extensions.cs similarity index 91% rename from OpenAI-DotNet-Tests/TestFixture_00_02_Tools.cs rename to OpenAI-DotNet-Tests/TestFixture_00_02_Extensions.cs index 70961d93..c79488a3 100644 --- a/OpenAI-DotNet-Tests/TestFixture_00_02_Tools.cs +++ b/OpenAI-DotNet-Tests/TestFixture_00_02_Extensions.cs @@ -2,6 +2,7 @@ using NUnit.Framework; using OpenAI.Images; +using OpenAI.Tests.StructuredOutput; using OpenAI.Tests.Weather; using System; using System.Collections.Generic; @@ -12,10 +13,10 @@ namespace OpenAI.Tests { - internal class TestFixture_00_02_Tools : AbstractTestFixture + internal class TestFixture_00_02_Extensions : AbstractTestFixture { [Test] - public void Test_01_GetTools() + public void Test_01_01_GetTools() { var tools = Tool.GetAllAvailableTools(forceUpdate: true, clearCache: true).ToList(); Assert.IsNotNull(tools); @@ -29,7 +30,7 @@ public void Test_01_GetTools() } [Test] - public async Task Test_02_Tool_Funcs() + public async Task Test_01_02_Tool_Funcs() { var tools = new List { @@ -115,7 +116,7 @@ private string FunctionWithArrayArgs(List args) } [Test] - public void Test_03_Tool_works_when_called_concurrently() + public void Test_01_03_Tool_works_when_called_concurrently() { Assert.Multiple(async () => { @@ -138,5 +139,12 @@ async Task Test(int id) Assert.AreEqual(id, result); } } + + [Test] + public void Test_02_01_GenerateJsonSchema() + { + JsonSchema mathSchema = typeof(MathResponse); + Console.WriteLine(mathSchema.ToString()); + } } } diff --git a/OpenAI-DotNet-Tests/TestFixture_03_Threads.cs b/OpenAI-DotNet-Tests/TestFixture_03_Threads.cs index 6bef392d..71c2978e 100644 --- a/OpenAI-DotNet-Tests/TestFixture_03_Threads.cs +++ b/OpenAI-DotNet-Tests/TestFixture_03_Threads.cs @@ -4,6 +4,7 @@ using OpenAI.Assistants; using OpenAI.Files; using OpenAI.Models; +using OpenAI.Tests.StructuredOutput; using OpenAI.Tests.Weather; using OpenAI.Threads; using System; @@ -672,66 +673,74 @@ public async Task Test_04_04_CreateThreadAndRun_SubmitToolOutput() 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( + 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)); + model: "gpt-4o-2024-08-06")); Assert.NotNull(assistant); ThreadResponse thread = null; + // check if any exceptions thrown in stream event handler + var exceptionThrown = false; try { - var run = await assistant.CreateThreadAndRunAsync("how can I solve 8x + 7 = -23", - async @event => + async Task StreamEventHandler(IServerSentEvent @event) + { + try { - Console.WriteLine(@event.ToJsonString()); - await Task.CompletedTask; - }); + switch (@event) + { + case MessageResponse message: + if (message.Status != MessageStatus.Completed) + { + Console.WriteLine(@event.ToJsonString()); + break; + } + + var mathResponse = message.FromSchema(); + Assert.IsNotNull(mathResponse); + Assert.IsNotNull(mathResponse.Steps); + Assert.IsNotEmpty(mathResponse.Steps); + + for (var i = 0; i < mathResponse.Steps.Count; i++) + { + var step = mathResponse.Steps[i]; + Assert.IsNotNull(step.Explanation); + Console.WriteLine($"Step {i}: {step.Explanation}"); + Assert.IsNotNull(step.Output); + Console.WriteLine($"Result: {step.Output}"); + } + + Assert.IsNotNull(mathResponse.FinalAnswer); + Console.WriteLine($"Final Answer: {mathResponse.FinalAnswer}"); + break; + default: + Console.WriteLine(@event.ToJsonString()); + break; + } + } + catch (Exception e) + { + Console.WriteLine(e); + exceptionThrown = true; + throw; + } + + await Task.CompletedTask; + } + + var run = await assistant.CreateThreadAndRunAsync("how can I solve 8x + 7 = -23", StreamEventHandler); Assert.IsNotNull(run); + Assert.IsFalse(exceptionThrown); 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) + foreach (var response in messages.Items.OrderBy(response => response.CreatedAt)) { Console.WriteLine($"{response.Role}: {response.PrintContent()}"); } diff --git a/OpenAI-DotNet-Tests/TestFixture_04_Chat.cs b/OpenAI-DotNet-Tests/TestFixture_04_Chat.cs index 6e99738c..2cccddd8 100644 --- a/OpenAI-DotNet-Tests/TestFixture_04_Chat.cs +++ b/OpenAI-DotNet-Tests/TestFixture_04_Chat.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using OpenAI.Chat; using OpenAI.Models; +using OpenAI.Tests.StructuredOutput; using OpenAI.Tests.Weather; using System; using System.Collections.Generic; @@ -537,52 +538,27 @@ public async Task Test_06_01_GetChat_JsonSchema() 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}"); + var chatRequest = new ChatRequest(messages, model: "gpt-4o-2024-08-06"); + var (mathResponse, chatResponse) = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); + Assert.IsNotNull(chatResponse); + Assert.IsNotNull(mathResponse); + Assert.IsNotEmpty(mathResponse.Steps); + Assert.IsNotNull(chatResponse.Choices); + Assert.IsNotEmpty(chatResponse.Choices); + + for (var i = 0; i < mathResponse.Steps.Count; i++) + { + var step = mathResponse.Steps[i]; + Assert.IsNotNull(step.Explanation); + Console.WriteLine($"Step {i}: {step.Explanation}"); + Assert.IsNotNull(step.Output); + Console.WriteLine($"Result: {step.Output}"); } - response.GetUsage(); + Assert.IsNotNull(mathResponse.FinalAnswer); + Console.WriteLine($"Final Answer: {mathResponse.FinalAnswer}"); + + chatResponse.GetUsage(); } [Test] @@ -595,43 +571,9 @@ public async Task Test_06_01_GetChat_JsonSchema_Streaming() 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 chatRequest = new ChatRequest(messages, model: "gpt-4o-2024-08-06"); var cumulativeDelta = string.Empty; - var response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => + var (mathResponse, chatResponse) = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => { Assert.IsNotNull(partialResponse); if (partialResponse.Usage != null) { return; } @@ -643,17 +585,29 @@ public async Task Test_06_01_GetChat_JsonSchema_Streaming() cumulativeDelta += choice.Delta.Content; } }, true); - Assert.IsNotNull(response); - Assert.IsNotNull(response.Choices); - var choice = response.FirstChoice; + Assert.IsNotNull(chatResponse); + Assert.IsNotNull(mathResponse); + Assert.IsNotNull(chatResponse.Choices); + var choice = chatResponse.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(); + + for (var i = 0; i < mathResponse.Steps.Count; i++) + { + var step = mathResponse.Steps[i]; + Assert.IsNotNull(step.Explanation); + Console.WriteLine($"Step {i}: {step.Explanation}"); + Assert.IsNotNull(step.Output); + Console.WriteLine($"Result: {step.Output}"); + } + + Assert.IsNotNull(mathResponse.FinalAnswer); + Console.WriteLine($"Final Answer: {mathResponse.FinalAnswer}"); + + chatResponse.GetUsage(); } } } diff --git a/OpenAI-DotNet-Tests/TestServices/DateTimeUtility.cs b/OpenAI-DotNet-Tests/TestServices/DateTimeUtility.cs new file mode 100644 index 00000000..9b8418e0 --- /dev/null +++ b/OpenAI-DotNet-Tests/TestServices/DateTimeUtility.cs @@ -0,0 +1,14 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Threading.Tasks; + +namespace OpenAI.Tests.Weather +{ + internal static class DateTimeUtility + { + [Function("Get the current date and time.")] + public static async Task GetDateTime() + => await Task.FromResult(DateTimeOffset.Now.ToString()); + } +} diff --git a/OpenAI-DotNet-Tests/TestServices/MathResponse.cs b/OpenAI-DotNet-Tests/TestServices/MathResponse.cs new file mode 100644 index 00000000..43701594 --- /dev/null +++ b/OpenAI-DotNet-Tests/TestServices/MathResponse.cs @@ -0,0 +1,29 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace OpenAI.Tests.StructuredOutput +{ + internal sealed class MathResponse + { + [JsonInclude] + [JsonPropertyName("steps")] + public IReadOnlyList Steps { get; private set; } + + [JsonInclude] + [JsonPropertyName("final_answer")] + public string FinalAnswer { get; private set; } + } + + internal sealed class MathStep + { + [JsonInclude] + [JsonPropertyName("explanation")] + public string Explanation { get; private set; } + + [JsonInclude] + [JsonPropertyName("output")] + public string Output { get; private set; } + } +} diff --git a/OpenAI-DotNet-Tests/TestServices/WeatherService.cs b/OpenAI-DotNet-Tests/TestServices/WeatherService.cs index e74535d1..6499c4f6 100644 --- a/OpenAI-DotNet-Tests/TestServices/WeatherService.cs +++ b/OpenAI-DotNet-Tests/TestServices/WeatherService.cs @@ -31,11 +31,4 @@ public static async Task GetCurrentWeatherAsync( public static int CelsiusToFahrenheit(int celsius) => (celsius * 9 / 5) + 32; } - - internal static class DateTimeUtility - { - [Function("Get the current date and time.")] - public static async Task GetDateTime() - => await Task.FromResult(DateTimeOffset.Now.ToString()); - } } diff --git a/OpenAI-DotNet/Assistants/AssistantsEndpoint.cs b/OpenAI-DotNet/Assistants/AssistantsEndpoint.cs index 45316fa5..980cf2f7 100644 --- a/OpenAI-DotNet/Assistants/AssistantsEndpoint.cs +++ b/OpenAI-DotNet/Assistants/AssistantsEndpoint.cs @@ -28,6 +28,27 @@ public async Task> ListAssistantsAsync(ListQuery return response.Deserialize>(responseAsString, client); } + /// + /// Create an assistant. + /// + /// to use for structured outputs. + /// . + /// Optional, . + /// . + public async Task CreateAssistantAsync(CreateAssistantRequest request = null, CancellationToken cancellationToken = default) + { + if (request == null) + { + request = new CreateAssistantRequest(jsonSchema: typeof(T)); + } + else + { + request.ResponseFormatObject = new ResponseFormatObject(typeof(T)); + } + + return await CreateAssistantAsync(request, cancellationToken); + } + /// /// Create an assistant. /// diff --git a/OpenAI-DotNet/Assistants/CreateAssistantRequest.cs b/OpenAI-DotNet/Assistants/CreateAssistantRequest.cs index 4b1c3c62..6a2bb7f0 100644 --- a/OpenAI-DotNet/Assistants/CreateAssistantRequest.cs +++ b/OpenAI-DotNet/Assistants/CreateAssistantRequest.cs @@ -276,7 +276,7 @@ public CreateAssistantRequest( /// [JsonPropertyName("response_format")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public ResponseFormatObject ResponseFormatObject { get; } + public ResponseFormatObject ResponseFormatObject { get; internal set; } [JsonIgnore] public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; diff --git a/OpenAI-DotNet/Chat/ChatEndpoint.cs b/OpenAI-DotNet/Chat/ChatEndpoint.cs index ffcde2a8..770c0632 100644 --- a/OpenAI-DotNet/Chat/ChatEndpoint.cs +++ b/OpenAI-DotNet/Chat/ChatEndpoint.cs @@ -42,6 +42,21 @@ public async Task GetCompletionAsync(ChatRequest chatRequest, Canc return response.Deserialize(responseAsString, client); } + /// + /// Creates a completion for the chat message. + /// + /// to use for structured outputs. + /// The chat request which contains the message content. + /// Optional, . + /// . + public async Task<(T, ChatResponse)> GetCompletionAsync(ChatRequest chatRequest, CancellationToken cancellationToken = default) + { + chatRequest.ResponseFormatObject = new ResponseFormatObject(typeof(T)); + var response = await GetCompletionAsync(chatRequest, cancellationToken).ConfigureAwait(false); + var output = JsonSerializer.Deserialize(response.FirstChoice, OpenAIClient.JsonSerializationOptions); + return (output, response); + } + /// /// Created a completion for the chat message and stream the results to the as they come in. /// @@ -57,11 +72,54 @@ public async Task GetCompletionAsync(ChatRequest chatRequest, Canc /// . public async Task StreamCompletionAsync(ChatRequest chatRequest, Action resultHandler, bool streamUsage = false, CancellationToken cancellationToken = default) => await StreamCompletionAsync(chatRequest, async response => + { + resultHandler.Invoke(response); + await Task.CompletedTask; + }, streamUsage, cancellationToken).ConfigureAwait(false); + + /// + /// Created a completion for the chat message and stream the results to the as they come in. + /// + /// to use for structured outputs. + /// The chat request which contains the message content. + /// An to be invoked as each new result arrives. + /// + /// Optional, If set, an additional chunk will be streamed before the 'data: [DONE]' message. + /// The 'usage' field on this chunk shows the token usage statistics for the entire request, + /// and the 'choices' field will always be an empty array. All other chunks will also include a 'usage' field, + /// but with a null value. + /// + /// Optional, . + /// . + public async Task<(T, ChatResponse)> StreamCompletionAsync(ChatRequest chatRequest, Action resultHandler, bool streamUsage = false, CancellationToken cancellationToken = default) + => await StreamCompletionAsync(chatRequest, async response => { resultHandler.Invoke(response); await Task.CompletedTask; }, streamUsage, cancellationToken); + /// + /// Created a completion for the chat message and stream the results to the as they come in. + /// + /// to use for structured outputs. + /// The chat request which contains the message content. + /// A to to be invoked as each new result arrives. + /// + /// Optional, If set, an additional chunk will be streamed before the 'data: [DONE]' message. + /// The 'usage' field on this chunk shows the token usage statistics for the entire request, + /// and the 'choices' field will always be an empty array. All other chunks will also include a 'usage' field, + /// but with a null value. + /// + /// Optional, . + /// . + public async Task<(T, ChatResponse)> StreamCompletionAsync(ChatRequest chatRequest, Func resultHandler, bool streamUsage = false, CancellationToken cancellationToken = default) + { + chatRequest.ResponseFormatObject = new ResponseFormatObject(typeof(T)); + var response = await StreamCompletionAsync(chatRequest, resultHandler, streamUsage, cancellationToken).ConfigureAwait(false); + var output = JsonSerializer.Deserialize(response.FirstChoice, OpenAIClient.JsonSerializationOptions); + return (output, response); + } + /// /// Created a completion for the chat message and stream the results to the as they come in. /// @@ -96,12 +154,12 @@ public async Task StreamCompletionAsync(ChatRequest chatRequest, F chatResponse.AppendFrom(partialResponse); } - await resultHandler.Invoke(partialResponse); + await resultHandler.Invoke(partialResponse).ConfigureAwait(false); }, cancellationToken); if (chatResponse == null) { return null; } chatResponse.SetResponseData(response.Headers, client); - await resultHandler.Invoke(chatResponse); + await resultHandler.Invoke(chatResponse).ConfigureAwait(false); return chatResponse; } diff --git a/OpenAI-DotNet/Chat/ChatRequest.cs b/OpenAI-DotNet/Chat/ChatRequest.cs index 291be8fd..2eaacb9a 100644 --- a/OpenAI-DotNet/Chat/ChatRequest.cs +++ b/OpenAI-DotNet/Chat/ChatRequest.cs @@ -282,7 +282,7 @@ public ChatRequest( [JsonPropertyName("response_format")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public ResponseFormatObject ResponseFormatObject { get; } + public ResponseFormatObject ResponseFormatObject { get; internal set; } /// /// This feature is in Beta. If specified, our system will make a best effort to sample deterministically, diff --git a/OpenAI-DotNet/Common/JsonSchema.cs b/OpenAI-DotNet/Common/JsonSchema.cs index 9cc5a91b..dfa136aa 100644 --- a/OpenAI-DotNet/Common/JsonSchema.cs +++ b/OpenAI-DotNet/Common/JsonSchema.cs @@ -1,5 +1,8 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. +using OpenAI.Extensions; +using System; +using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -65,7 +68,7 @@ public JsonSchema(string name, JsonNode schema, string description = null, bool /// [JsonInclude] [JsonPropertyName("strict")] - public bool Strict { get; private set; } + public bool Strict { get; private set; } = true; /// /// The schema for the response format, described as a JSON Schema object. @@ -75,5 +78,11 @@ public JsonSchema(string name, JsonNode schema, string description = null, bool public JsonNode Schema { get; private set; } public static implicit operator ResponseFormatObject(JsonSchema jsonSchema) => new(jsonSchema); + + public static implicit operator JsonSchema(Type type) => new(type.Name, type.GenerateJsonSchema()); + + /// + public override string ToString() + => JsonSerializer.Serialize(this, ResponseExtensions.DebugJsonOptions); } } diff --git a/OpenAI-DotNet/Extensions/ResponseExtensions.cs b/OpenAI-DotNet/Extensions/ResponseExtensions.cs index 24172761..057a9b51 100644 --- a/OpenAI-DotNet/Extensions/ResponseExtensions.cs +++ b/OpenAI-DotNet/Extensions/ResponseExtensions.cs @@ -36,7 +36,7 @@ internal static class ResponseExtensions NumberDecimalSeparator = "." }; - private static readonly JsonSerializerOptions debugJsonOptions = new() + public static readonly JsonSerializerOptions DebugJsonOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping @@ -274,7 +274,7 @@ internal static async Task ReadAsStringAsync(this HttpResponseMessage re } } - debugMessage.Append(JsonSerializer.Serialize(debugMessageObject, debugJsonOptions)); + debugMessage.Append(JsonSerializer.Serialize(debugMessageObject, DebugJsonOptions)); Console.WriteLine(debugMessage.ToString()); } @@ -307,7 +307,7 @@ internal static T Deserialize(this HttpResponseMessage response, ServerSentEv } catch (Exception e) { - Console.WriteLine($"Failed to parse {typeof(T).Name} -> {jNode.ToJsonString(debugJsonOptions)}\n{e}"); + Console.WriteLine($"Failed to parse {typeof(T).Name} -> {jNode.ToJsonString(DebugJsonOptions)}\n{e}"); throw; } diff --git a/OpenAI-DotNet/Extensions/TypeExtensions.cs b/OpenAI-DotNet/Extensions/TypeExtensions.cs index 97e7a5ed..4ca7fa1f 100644 --- a/OpenAI-DotNet/Extensions/TypeExtensions.cs +++ b/OpenAI-DotNet/Extensions/TypeExtensions.cs @@ -14,14 +14,13 @@ internal static class TypeExtensions { public static JsonObject GenerateJsonSchema(this MethodInfo methodInfo, JsonSerializerOptions options = null) { - var parameters = methodInfo.GetParameters(); - var schema = new JsonObject { ["type"] = "object", ["properties"] = new JsonObject() }; var requiredParameters = new JsonArray(); + var parameters = methodInfo.GetParameters(); foreach (var parameter in parameters) { @@ -56,6 +55,34 @@ public static JsonObject GenerateJsonSchema(this MethodInfo methodInfo, JsonSeri return schema; } + public static JsonObject GenerateJsonSchema(this Type type, JsonSerializerOptions options = null) + { + var schema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject() + }; + + var requiredProperties = new JsonArray(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + foreach (var property in properties) + { + var propertyNameAttribute = property.GetCustomAttribute(); + var propertyName = propertyNameAttribute?.Name ?? property.Name; + requiredProperties.Add(propertyName); + schema["properties"]![propertyName] = GenerateJsonSchema(property.PropertyType, schema, options); + } + + if (requiredProperties.Count > 0) + { + schema["required"] = requiredProperties; + } + + schema["additionalProperties"] = false; + return schema; + } + public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchema, JsonSerializerOptions options = null) { options ??= OpenAIClient.JsonSerializationOptions; @@ -116,7 +143,9 @@ public static JsonObject GenerateJsonSchema(this Type type, JsonObject rootSchem schema["enum"].AsArray().Add(JsonNode.Parse(JsonSerializer.Serialize(value, options))); } } - else if (type.IsArray || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>))) + else if (type.IsArray || + type.IsGenericType && (type.GetGenericTypeDefinition() == typeof(List<>) || + type.GetGenericTypeDefinition() == typeof(IReadOnlyList<>))) { schema["type"] = "array"; var elementType = type.GetElementType() ?? type.GetGenericArguments()[0]; diff --git a/OpenAI-DotNet/OpenAI-DotNet.csproj b/OpenAI-DotNet/OpenAI-DotNet.csproj index 1160d427..a1005ba2 100644 --- a/OpenAI-DotNet/OpenAI-DotNet.csproj +++ b/OpenAI-DotNet/OpenAI-DotNet.csproj @@ -29,8 +29,10 @@ More context [on Roger Pincombe's blog](https://rogerpincombe.com/openai-dotnet- OpenAI-DotNet.pfx true true - 8.2.1 + 8.2.2 +Version 8.2.2 +- Added generic parameters to methods that support structured output Version 8.2.1 - Fixed a typo in the Azure OpenAI URL base endpoint Version 8.2.0 diff --git a/OpenAI-DotNet/Threads/CreateRunRequest.cs b/OpenAI-DotNet/Threads/CreateRunRequest.cs index 8b94ed6d..89e01b8e 100644 --- a/OpenAI-DotNet/Threads/CreateRunRequest.cs +++ b/OpenAI-DotNet/Threads/CreateRunRequest.cs @@ -19,6 +19,7 @@ public sealed class CreateRunRequest /// The ID of the assistant used for execution of this run. /// /// . + [Obsolete("removed")] public CreateRunRequest(string assistantId, CreateRunRequest request) : this( assistantId, @@ -191,7 +192,7 @@ public CreateRunRequest( /// The ID of the assistant used for execution of this run. /// [JsonPropertyName("assistant_id")] - public string AssistantId { get; } + public string AssistantId { get; internal set; } /// /// The model that the assistant used for this run. @@ -317,7 +318,7 @@ public CreateRunRequest( /// [JsonPropertyName("response_format")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public ResponseFormatObject ResponseFormatObject { get; } + public ResponseFormatObject ResponseFormatObject { get; internal set; } [JsonIgnore] public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; diff --git a/OpenAI-DotNet/Threads/CreateThreadAndRunRequest.cs b/OpenAI-DotNet/Threads/CreateThreadAndRunRequest.cs index 50dd02aa..1b4d12fc 100644 --- a/OpenAI-DotNet/Threads/CreateThreadAndRunRequest.cs +++ b/OpenAI-DotNet/Threads/CreateThreadAndRunRequest.cs @@ -16,6 +16,7 @@ public sealed class CreateThreadAndRunRequest /// The ID of the assistant to use to execute this run. /// /// . + [Obsolete("removed")] public CreateThreadAndRunRequest(string assistantId, CreateThreadAndRunRequest request) : this( assistantId, @@ -194,7 +195,7 @@ public CreateThreadAndRunRequest( /// The ID of the assistant to use to execute this run. /// [JsonPropertyName("assistant_id")] - public string AssistantId { get; } + public string AssistantId { get; internal set; } /// /// The ID of the Model to be used to execute this run. @@ -319,7 +320,7 @@ public CreateThreadAndRunRequest( /// [JsonPropertyName("response_format")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public ResponseFormatObject ResponseFormatObject { get; } + public ResponseFormatObject ResponseFormatObject { get; internal set; } [JsonIgnore] public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; diff --git a/OpenAI-DotNet/Threads/MessageResponse.cs b/OpenAI-DotNet/Threads/MessageResponse.cs index 64eb540e..5b953840 100644 --- a/OpenAI-DotNet/Threads/MessageResponse.cs +++ b/OpenAI-DotNet/Threads/MessageResponse.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Text.Json.Serialization; namespace OpenAI.Threads @@ -177,6 +178,18 @@ public string PrintContent() ? string.Empty : string.Join("\n", content.Select(c => c?.ToString())); + /// + /// Converts the to the specified . + /// + /// to used for structured outputs. + /// . + /// Deserialized object. + public T FromSchema(JsonSerializerOptions options = null) + { + options ??= OpenAIClient.JsonSerializationOptions; + return JsonSerializer.Deserialize(PrintContent(), options); + } + internal void AppendFrom(MessageResponse other) { if (other == null) { return; } diff --git a/OpenAI-DotNet/Threads/ThreadsEndpoint.cs b/OpenAI-DotNet/Threads/ThreadsEndpoint.cs index 479e50f2..3142f874 100644 --- a/OpenAI-DotNet/Threads/ThreadsEndpoint.cs +++ b/OpenAI-DotNet/Threads/ThreadsEndpoint.cs @@ -197,7 +197,7 @@ public async Task CreateRunAsync(string threadId, CreateRunRequest { streamEventHandler.Invoke(serverSentEvent); return Task.CompletedTask; - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); /// /// Create a run. @@ -211,7 +211,39 @@ public async Task CreateRunAsync(string threadId, CreateRunRequest => await CreateRunAsync(threadId, request, streamEventHandler == null ? null : async (_, serverSentEvent) => { await streamEventHandler.Invoke(serverSentEvent); - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); + + /// + /// Create a run. + /// + /// to use for structured outputs. + /// The id of the thread to run. + /// . + /// Optional, stream callback handler. + /// Optional, . + /// . + public async Task CreateRunAsync(string threadId, CreateRunRequest request = null, Func streamEventHandler = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request?.AssistantId)) + { + var assistant = await client.AssistantsEndpoint.CreateAssistantAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + if (request == null) + { + request = new CreateRunRequest(assistant); + } + else + { + request.AssistantId = assistant.Id; + } + } + + request.ResponseFormatObject = new ResponseFormatObject(typeof(T)); + return await CreateRunAsync(threadId, request, streamEventHandler == null ? null : async (_, serverSentEvent) => + { + await streamEventHandler.Invoke(serverSentEvent); + }, cancellationToken).ConfigureAwait(false); + } /// /// Create a run. @@ -223,10 +255,18 @@ public async Task CreateRunAsync(string threadId, CreateRunRequest /// . public async Task CreateRunAsync(string threadId, CreateRunRequest request = null, Func streamEventHandler = null, CancellationToken cancellationToken = default) { - if (request == null || string.IsNullOrWhiteSpace(request.AssistantId)) + if (string.IsNullOrWhiteSpace(request?.AssistantId)) { var assistant = await client.AssistantsEndpoint.CreateAssistantAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - request = new CreateRunRequest(assistant, request); + + if (request == null) + { + request = new CreateRunRequest(assistant); + } + else + { + request.AssistantId = assistant.Id; + } } request.Stream = streamEventHandler != null; @@ -235,7 +275,7 @@ public async Task CreateRunAsync(string threadId, CreateRunRequest if (request.Stream) { - return await StreamRunAsync(endpoint, payload, streamEventHandler, cancellationToken); + return await StreamRunAsync(endpoint, payload, streamEventHandler, cancellationToken).ConfigureAwait(false); } using var response = await client.Client.PostAsync(endpoint, payload, cancellationToken).ConfigureAwait(false); @@ -249,7 +289,7 @@ public async Task CreateThreadAndRunAsync(CreateThreadAndRunRequest { streamEventHandler.Invoke(serverSentEvent); return Task.CompletedTask; - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); /// /// Create a thread and run it in one request. @@ -262,7 +302,38 @@ public async Task CreateThreadAndRunAsync(CreateThreadAndRunRequest => await CreateThreadAndRunAsync(request, streamEventHandler == null ? null : async (_, serverSentEvent) => { await streamEventHandler.Invoke(serverSentEvent); - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); + + /// + /// Create a thread and run it in one request. + /// + /// to use for structured outputs. + /// . + /// Optional, stream callback handler. + /// Optional, . + /// . + public async Task CreateThreadAndRunAsync(CreateThreadAndRunRequest request = null, Func streamEventHandler = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request?.AssistantId)) + { + var assistant = await client.AssistantsEndpoint.CreateAssistantAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + if (request == null) + { + request = new CreateThreadAndRunRequest(assistant, jsonSchema: typeof(T)); + } + else + { + request.AssistantId = assistant.Id; + } + } + + request.ResponseFormatObject = new ResponseFormatObject(typeof(T)); + return await CreateThreadAndRunAsync(request, streamEventHandler == null ? null : async (_, serverSentEvent) => + { + await streamEventHandler.Invoke(serverSentEvent).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + } /// /// Create a thread and run it in one request. @@ -273,10 +344,18 @@ public async Task CreateThreadAndRunAsync(CreateThreadAndRunRequest /// . public async Task CreateThreadAndRunAsync(CreateThreadAndRunRequest request = null, Func streamEventHandler = null, CancellationToken cancellationToken = default) { - if (request == null || string.IsNullOrWhiteSpace(request.AssistantId)) + if (string.IsNullOrWhiteSpace(request?.AssistantId)) { var assistant = await client.AssistantsEndpoint.CreateAssistantAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - request = new CreateThreadAndRunRequest(assistant, request); + + if (request == null) + { + request = new CreateThreadAndRunRequest(assistant); + } + else + { + request.AssistantId = assistant.Id; + } } request.Stream = streamEventHandler != null; @@ -285,7 +364,7 @@ public async Task CreateThreadAndRunAsync(CreateThreadAndRunRequest if (request.Stream) { - return await StreamRunAsync(endpoint, payload, streamEventHandler, cancellationToken); + return await StreamRunAsync(endpoint, payload, streamEventHandler, cancellationToken).ConfigureAwait(false); } using var response = await client.Client.PostAsync(endpoint, payload, cancellationToken).ConfigureAwait(false); @@ -334,7 +413,7 @@ public async Task SubmitToolOutputsAsync(string threadId, string ru { streamEventHandler.Invoke(serverSentEvent); return Task.CompletedTask; - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); /// /// When a run has the status: "requires_action" and required_action.type is submit_tool_outputs, @@ -351,7 +430,7 @@ public async Task SubmitToolOutputsAsync(string threadId, string ru => await SubmitToolOutputsAsync(threadId, runId, request, streamEventHandler == null ? null : async (_, serverSentEvent) => { await streamEventHandler.Invoke(serverSentEvent); - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); /// /// When a run has the status: "requires_action" and required_action.type is submit_tool_outputs, @@ -372,7 +451,7 @@ public async Task SubmitToolOutputsAsync(string threadId, string ru if (request.Stream) { - return await StreamRunAsync(endpoint, payload, streamEventHandler, cancellationToken); + return await StreamRunAsync(endpoint, payload, streamEventHandler, cancellationToken).ConfigureAwait(false); } using var response = await client.Client.PostAsync(endpoint, payload, cancellationToken).ConfigureAwait(false); @@ -573,12 +652,12 @@ private async Task StreamRunAsync(string endpoint, StringContent pa } finally { - await streamEventHandler.Invoke(@event, serverSentEvent); + await streamEventHandler.Invoke(@event, serverSentEvent).ConfigureAwait(false); } }, cancellationToken); if (run == null) { return null; } - run = await run.WaitForStatusChangeAsync(timeout: -1, cancellationToken: cancellationToken); + run = await run.WaitForStatusChangeAsync(timeout: -1, cancellationToken: cancellationToken).ConfigureAwait(false); run.SetResponseData(response.Headers, client); return run; } diff --git a/README.md b/README.md index ef4e440d..9266a787 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ dotnet add package OpenAI-DotNet - [Streaming](#chat-streaming) - [Tools](#chat-tools) - [Vision](#chat-vision) - - [Json Schema](#chat-json-schema) :new: + - [Json Schema](#chat-structured-outputs) :new: - [Json Mode](#chat-json-mode) - [Audio](#audio) - [Create Speech](#create-speech) @@ -816,62 +816,90 @@ Structured Outputs is the evolution of JSON mode. While both ensure valid JSON i > - 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. +First define the structure of your responses. These will be used as your schema. +These are the objects you'll deserialize to, so be sure to use standard Json object models. + ```csharp -var mathSchema = new JsonSchema("math_response", @" +public class MathResponse { - ""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( + [JsonInclude] + [JsonPropertyName("steps")] + public IReadOnlyList Steps { get; private set; } + + [JsonInclude] + [JsonPropertyName("final_answer")] + public string FinalAnswer { get; private set; } +} + +public class MathStep +{ + [JsonInclude] + [JsonPropertyName("explanation")] + public string Explanation { get; private set; } + + [JsonInclude] + [JsonPropertyName("output")] + public string Output { get; private set; } +} +``` + +To use, simply specify the `MathResponse` type as a generic constraint in either `CreateAssistantAsync`, `CreateRunAsync`, or `CreateThreadAndRunAsync`. + +```csharp +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)); + model: "gpt-4o-2024-08-06")); ThreadResponse thread = null; try { - var run = await assistant.CreateThreadAndRunAsync("how can I solve 8x + 7 = -23", - async @event => + async Task StreamEventHandler(IServerSentEvent @event) + { + try { - Console.WriteLine(@event.ToJsonString()); - await Task.CompletedTask; - }); + switch (@event) + { + case MessageResponse message: + if (message.Status != MessageStatus.Completed) + { + Console.WriteLine(@event.ToJsonString()); + break; + } + + var mathResponse = message.FromSchema(); + + for (var i = 0; i < mathResponse.Steps.Count; i++) + { + var step = mathResponse.Steps[i]; + Console.WriteLine($"Step {i}: {step.Explanation}"); + Console.WriteLine($"Result: {step.Output}"); + } + + Console.WriteLine($"Final Answer: {mathResponse.FinalAnswer}"); + break; + default: + Console.WriteLine(@event.ToJsonString()); + break; + } + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + + await Task.CompletedTask; + } + + var run = await assistant.CreateThreadAndRunAsync("how can I solve 8x + 7 = -23", StreamEventHandler); 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) + foreach (var response in messages.Items.OrderBy(response => response.CreatedAt)) { Console.WriteLine($"{response.Role}: {response.PrintContent()}"); } @@ -883,7 +911,6 @@ finally if (thread != null) { var isDeleted = await thread.DeleteAsync(deleteToolResources: true); - Assert.IsTrue(isDeleted); } } ``` @@ -1259,7 +1286,7 @@ var response = await api.ChatEndpoint.GetCompletionAsync(chatRequest); Console.WriteLine($"{response.FirstChoice.Message.Role}: {response.FirstChoice.Message.Content} | Finish Reason: {response.FirstChoice.FinishDetails}"); ``` -#### [Chat Json Schema](https://platform.openai.com/docs/guides/structured-outputs) +#### [Chat Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs) The evolution of [Json Mode](#chat-json-mode). While both ensure valid JSON is produced, only Structured Outputs ensure schema adherence. @@ -1268,6 +1295,35 @@ The evolution of [Json Mode](#chat-json-mode). While both ensure valid JSON is > - 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. +First define the structure of your responses. These will be used as your schema. +These are the objects you'll deserialize to, so be sure to use standard Json object models. + +```csharp +public class MathResponse +{ + [JsonInclude] + [JsonPropertyName("steps")] + public IReadOnlyList Steps { get; private set; } + + [JsonInclude] + [JsonPropertyName("final_answer")] + public string FinalAnswer { get; private set; } +} + +public class MathStep +{ + [JsonInclude] + [JsonPropertyName("explanation")] + public string Explanation { get; private set; } + + [JsonInclude] + [JsonPropertyName("output")] + public string Output { get; private set; } +} +``` + +To use, simply specify the `MathResponse` type as a generic constraint when requesting a completion. + ```csharp var messages = new List { @@ -1275,48 +1331,18 @@ var messages = new List 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); +var chatRequest = new ChatRequest(messages, model: new("gpt-4o-2024-08-06")); +var (mathResponse, chatResponse) = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); -foreach (var choice in response.Choices) +for (var i = 0; i < mathResponse.Steps.Count; i++) { - Console.WriteLine($"[{choice.Index}] {choice.Message.Role}: {choice} | Finish Reason: {choice.FinishReason}"); + var step = mathResponse.Steps[i]; + Console.WriteLine($"Step {i}: {step.Explanation}"); + Console.WriteLine($"Result: {step.Output}"); } -response.GetUsage(); +Console.WriteLine($"Final Answer: {mathResponse.FinalAnswer}"); +chatResponse.GetUsage(); ``` #### [Chat Json Mode](https://platform.openai.com/docs/guides/text-generation/json-mode)