diff --git a/OpenAI/Packages/com.openai.unity/Documentation~/README.md b/OpenAI/Packages/com.openai.unity/Documentation~/README.md index cbaf9342..2736a9c5 100644 --- a/OpenAI/Packages/com.openai.unity/Documentation~/README.md +++ b/OpenAI/Packages/com.openai.unity/Documentation~/README.md @@ -113,7 +113,7 @@ The recommended installation method is though the unity package manager and [Ope - [Streaming](#chat-streaming) - [Tools](#chat-tools) - [Vision](#chat-vision) - - [Json Schema](#chat-json-schema) :new: + - [Structured Outputs](#chat-structured-outputs) :new: - [Json Mode](#chat-json-mode) - [Audio](#audio) - [Create Speech](#create-speech) @@ -814,62 +814,86 @@ 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( + [JsonProperty("steps")] + public IReadOnlyList Steps { get; private set; } + + [JsonProperty("final_answer")] + public string FinalAnswer { get; private set; } +} + +public class MathStep +{ + [JsonProperty("explanation")] + public string Explanation { get; private set; } + + [JsonProperty("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 { - Debug.Log(@event.ToJsonString()); - await Task.CompletedTask; - }); + switch (@event) + { + case MessageResponse message: + if (message.Status != MessageStatus.Completed) + { + Debug.Log(@event.ToJsonString()); + break; + } + + var mathResponse = message.FromSchema(); + + for (var i = 0; i < mathResponse.Steps.Count; i++) + { + var step = mathResponse.Steps[i]; + Debug.Log($"Step {i}: {step.Explanation}"); + Debug.Log($"Result: {step.Output}"); + } + + Debug.Log($"Final Answer: {mathResponse.FinalAnswer}"); + break; + default: + Debug.Log(@event.ToJsonString()); + break; + } + } + catch (Exception e) + { + Debug.Log(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(); Debug.Log($"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)) { Debug.Log($"{response.Role}: {response.PrintContent()}"); } @@ -881,7 +905,6 @@ finally if (thread != null) { var isDeleted = await thread.DeleteAsync(deleteToolResources: true); - Assert.IsTrue(isDeleted); } } ``` @@ -1251,7 +1274,7 @@ var result = await api.ChatEndpoint.GetCompletionAsync(chatRequest); Debug.Log($"{result.FirstChoice.Message.Role}: {result.FirstChoice} | Finish Reason: {result.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. @@ -1260,6 +1283,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 { @@ -1267,48 +1319,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++) { - Debug.Log($"[{choice.Index}] {choice.Message.Role}: {choice} | Finish Reason: {choice.FinishReason}"); + var step = mathResponse.Steps[i]; + Debug.Log($"Step {i}: {step.Explanation}"); + Debug.Log($"Result: {step.Output}"); } -response.GetUsage(); +Debug.Log($"Final Answer: {mathResponse.FinalAnswer}"); +chatResponse.GetUsage(); ``` #### [Chat Json Mode](https://platform.openai.com/docs/guides/text-generation/json-mode) diff --git a/OpenAI/Packages/com.openai.unity/Runtime/Assistants/AssistantsEndpoint.cs b/OpenAI/Packages/com.openai.unity/Runtime/Assistants/AssistantsEndpoint.cs index 7ba066da..7d4a2515 100644 --- a/OpenAI/Packages/com.openai.unity/Runtime/Assistants/AssistantsEndpoint.cs +++ b/OpenAI/Packages/com.openai.unity/Runtime/Assistants/AssistantsEndpoint.cs @@ -29,6 +29,27 @@ public async Task> ListAssistantsAsync(ListQuery return response.Deserialize>(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/Packages/com.openai.unity/Runtime/Assistants/CreateAssistantRequest.cs b/OpenAI/Packages/com.openai.unity/Runtime/Assistants/CreateAssistantRequest.cs index dc26aae0..458e4ae6 100644 --- a/OpenAI/Packages/com.openai.unity/Runtime/Assistants/CreateAssistantRequest.cs +++ b/OpenAI/Packages/com.openai.unity/Runtime/Assistants/CreateAssistantRequest.cs @@ -290,7 +290,7 @@ public CreateAssistantRequest( /// [Preserve] [JsonProperty("response_format", DefaultValueHandling = DefaultValueHandling.Ignore)] - public ResponseFormatObject ResponseFormatObject { get; } + public ResponseFormatObject ResponseFormatObject { get; internal set; } [JsonIgnore] public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; diff --git a/OpenAI/Packages/com.openai.unity/Runtime/Chat/ChatEndpoint.cs b/OpenAI/Packages/com.openai.unity/Runtime/Chat/ChatEndpoint.cs index 57a97346..dfcfe80c 100644 --- a/OpenAI/Packages/com.openai.unity/Runtime/Chat/ChatEndpoint.cs +++ b/OpenAI/Packages/com.openai.unity/Runtime/Chat/ChatEndpoint.cs @@ -37,6 +37,21 @@ public async Task GetCompletionAsync(ChatRequest chatRequest, Canc return response.Deserialize(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); + var output = JsonConvert.DeserializeObject(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,6 +72,49 @@ public async Task StreamCompletionAsync(ChatRequest chatRequest, A return 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. + /// 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); + var output = JsonConvert.DeserializeObject(response.FirstChoice, OpenAIClient.JsonSerializationOptions); + return (output, response); + } + /// /// Created a completion for the chat message and stream the results to the as they come in. /// diff --git a/OpenAI/Packages/com.openai.unity/Runtime/Chat/ChatRequest.cs b/OpenAI/Packages/com.openai.unity/Runtime/Chat/ChatRequest.cs index 96f904a8..14b29871 100644 --- a/OpenAI/Packages/com.openai.unity/Runtime/Chat/ChatRequest.cs +++ b/OpenAI/Packages/com.openai.unity/Runtime/Chat/ChatRequest.cs @@ -287,7 +287,7 @@ public ChatRequest( /// [Preserve] [JsonProperty("response_format")] - public ResponseFormatObject ResponseFormatObject { get; } + public ResponseFormatObject ResponseFormatObject { get; internal set; } [JsonIgnore] public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; diff --git a/OpenAI/Packages/com.openai.unity/Runtime/Common/JsonSchema.cs b/OpenAI/Packages/com.openai.unity/Runtime/Common/JsonSchema.cs index dd4fd92b..55d41421 100644 --- a/OpenAI/Packages/com.openai.unity/Runtime/Common/JsonSchema.cs +++ b/OpenAI/Packages/com.openai.unity/Runtime/Common/JsonSchema.cs @@ -2,6 +2,8 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using OpenAI.Extensions; +using System; using UnityEngine.Scripting; namespace OpenAI @@ -49,14 +51,14 @@ public JsonSchema( /// [Preserve] [JsonProperty("name")] - public string Name { get; } + 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. /// [Preserve] [JsonProperty("description")] - public string Description { get; } + public string Description { get; private set; } /// /// Whether to enable strict schema adherence when generating the output. @@ -67,16 +69,22 @@ public JsonSchema( /// [Preserve] [JsonProperty("strict")] - public bool Strict { get; } + public bool Strict { get; private set; } /// /// The schema for the response format, described as a JSON Schema object. /// [Preserve] [JsonProperty("schema")] - public JToken Schema { get; } + public JToken Schema { get; private set; } [Preserve] 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() + => JsonConvert.SerializeObject(this, OpenAIClient.JsonSerializationOptions); } } diff --git a/OpenAI/Packages/com.openai.unity/Runtime/Extensions/TypeExtensions.cs b/OpenAI/Packages/com.openai.unity/Runtime/Extensions/TypeExtensions.cs index 4a410b78..bbc64d02 100644 --- a/OpenAI/Packages/com.openai.unity/Runtime/Extensions/TypeExtensions.cs +++ b/OpenAI/Packages/com.openai.unity/Runtime/Extensions/TypeExtensions.cs @@ -13,14 +13,14 @@ internal static class TypeExtensions { public static JObject GenerateJsonSchema(this MethodInfo methodInfo, JsonSerializer serializer = null) { - var parameters = methodInfo.GetParameters(); - var schema = new JObject { ["type"] = "object", ["properties"] = new JObject() }; + var requiredParameters = new JArray(); + var parameters = methodInfo.GetParameters(); foreach (var parameter in parameters) { @@ -55,6 +55,34 @@ public static JObject GenerateJsonSchema(this MethodInfo methodInfo, JsonSeriali return schema; } + public static JObject GenerateJsonSchema(this Type type, JsonSerializer serializer = null) + { + var schema = new JObject + { + ["type"] = "object", + ["properties"] = new JObject() + }; + + var requiredProperties = new JArray(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + foreach (var property in properties) + { + var propertyNameAttribute = property.GetCustomAttribute(); + var propertyName = propertyNameAttribute?.PropertyName ?? property.Name; + requiredProperties.Add(propertyName); + schema["properties"]![propertyName] = GenerateJsonSchema(property.PropertyType, schema, serializer); + } + + if (requiredProperties.Count > 0) + { + schema["required"] = requiredProperties; + } + + schema["additionalProperties"] = false; + return schema; + } + public static JObject GenerateJsonSchema(this Type type, JObject rootSchema, JsonSerializer serializer = null) { serializer ??= OpenAIClient.JsonSerializer; @@ -115,7 +143,9 @@ public static JObject GenerateJsonSchema(this Type type, JObject rootSchema, Jso ((JArray)schema["enum"]).Add(JToken.FromObject(value, serializer)); } } - 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]; @@ -158,7 +188,7 @@ public static JObject GenerateJsonSchema(this Type type, JObject rootSchema, Jso JObject propertyInfo; if (rootSchema["definitions"] != null && - ((JObject)rootSchema["definitions"]).ContainsKey(memberType.FullName)) + ((JObject)rootSchema["definitions"]).ContainsKey(memberType.FullName!)) { propertyInfo = new JObject { ["$ref"] = $"#/definitions/{memberType.FullName}" }; } @@ -218,11 +248,11 @@ public static JObject GenerateJsonSchema(this Type type, JObject rootSchema, Jso { switch (jsonPropertyAttribute.Required) { + case Required.Default: case Required.Always: case Required.AllowNull: requiredMembers.Add(propertyName); break; - case Required.Default: case Required.DisallowNull: default: requiredMembers.Remove(propertyName); diff --git a/OpenAI/Packages/com.openai.unity/Runtime/Threads/CreateRunRequest.cs b/OpenAI/Packages/com.openai.unity/Runtime/Threads/CreateRunRequest.cs index 97345f23..b8a35c98 100644 --- a/OpenAI/Packages/com.openai.unity/Runtime/Threads/CreateRunRequest.cs +++ b/OpenAI/Packages/com.openai.unity/Runtime/Threads/CreateRunRequest.cs @@ -21,7 +21,7 @@ public sealed class CreateRunRequest /// The ID of the assistant used for execution of this run. /// /// . - [Preserve] + [Obsolete("Removed")] public CreateRunRequest(string assistantId, CreateRunRequest request) : this( assistantId, @@ -196,7 +196,7 @@ public CreateRunRequest( /// [Preserve] [JsonProperty("assistant_id")] - public string AssistantId { get; } + public string AssistantId { get; internal set; } /// /// The model that the assistant used for this run. @@ -331,7 +331,7 @@ public CreateRunRequest( /// [Preserve] [JsonProperty("response_format")] - public ResponseFormatObject ResponseFormatObject { get; } + public ResponseFormatObject ResponseFormatObject { get; internal set; } [JsonIgnore] public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; diff --git a/OpenAI/Packages/com.openai.unity/Runtime/Threads/CreateThreadAndRunRequest.cs b/OpenAI/Packages/com.openai.unity/Runtime/Threads/CreateThreadAndRunRequest.cs index eac347fc..98644bd6 100644 --- a/OpenAI/Packages/com.openai.unity/Runtime/Threads/CreateThreadAndRunRequest.cs +++ b/OpenAI/Packages/com.openai.unity/Runtime/Threads/CreateThreadAndRunRequest.cs @@ -18,7 +18,7 @@ public sealed class CreateThreadAndRunRequest /// The ID of the assistant to use to execute this run. /// /// . - [Preserve] + [Obsolete("removed")] public CreateThreadAndRunRequest(string assistantId, CreateThreadAndRunRequest request) : this( assistantId, @@ -199,7 +199,7 @@ public CreateThreadAndRunRequest( /// [Preserve] [JsonProperty("assistant_id")] - public string AssistantId { get; } + public string AssistantId { get; internal set; } /// /// The ID of the Model to be used to execute this run. @@ -332,7 +332,7 @@ public CreateThreadAndRunRequest( /// [Preserve] [JsonProperty("response_format")] - public ResponseFormatObject ResponseFormatObject { get; } + public ResponseFormatObject ResponseFormatObject { get; internal set; } [JsonIgnore] public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto; diff --git a/OpenAI/Packages/com.openai.unity/Runtime/Threads/MessageResponse.cs b/OpenAI/Packages/com.openai.unity/Runtime/Threads/MessageResponse.cs index 817db169..c6d7b304 100644 --- a/OpenAI/Packages/com.openai.unity/Runtime/Threads/MessageResponse.cs +++ b/OpenAI/Packages/com.openai.unity/Runtime/Threads/MessageResponse.cs @@ -209,6 +209,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(JsonSerializerSettings settings = null) + { + settings ??= OpenAIClient.JsonSerializationOptions; + return JsonConvert.DeserializeObject(PrintContent(), settings); + } + internal void AppendFrom(MessageResponse other) { if (other == null) { return; } diff --git a/OpenAI/Packages/com.openai.unity/Runtime/Threads/ThreadsEndpoint.cs b/OpenAI/Packages/com.openai.unity/Runtime/Threads/ThreadsEndpoint.cs index e66bc7f1..c6edfc37 100644 --- a/OpenAI/Packages/com.openai.unity/Runtime/Threads/ThreadsEndpoint.cs +++ b/OpenAI/Packages/com.openai.unity/Runtime/Threads/ThreadsEndpoint.cs @@ -215,6 +215,39 @@ public async Task CreateRunAsync(string threadId, CreateRunRequest await streamEventHandler.Invoke(serverSentEvent); }, cancellationToken); + /// + /// 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); + } + /// /// Create a run. /// @@ -225,10 +258,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); - request = new CreateRunRequest(assistant, request); + + if (request == null) + { + request = new CreateRunRequest(assistant); + } + else + { + request.AssistantId = assistant.Id; + } } request.Stream = streamEventHandler != null; @@ -266,6 +307,37 @@ public async Task CreateThreadAndRunAsync(CreateThreadAndRunRequest await streamEventHandler.Invoke(serverSentEvent); }, cancellationToken); + /// + /// 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); + }, cancellationToken); + } + /// /// Create a thread and run it in one request. /// @@ -275,10 +347,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); - request = new CreateThreadAndRunRequest(assistant, request); + + if (request == null) + { + request = new CreateThreadAndRunRequest(assistant); + } + else + { + request.AssistantId = assistant.Id; + } } request.Stream = streamEventHandler != null; diff --git a/OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_01_Authentication.cs b/OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_01_Authentication.cs index eca62200..7322fc6a 100644 --- a/OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_01_Authentication.cs +++ b/OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_01_Authentication.cs @@ -186,9 +186,9 @@ public void Test_11_AzureConfigurationSettings() var auth = new OpenAIAuthentication("testKeyAaBbCcDd"); var settings = new OpenAISettings(resourceName: "test-resource", deploymentId: "deployment-id-test"); var api = new OpenAIClient(auth, settings); - Debug.Log(api.Settings.Info.DeploymentId); Debug.Log(api.Settings.Info.BaseRequest); Debug.Log(api.Settings.Info.BaseRequestUrlFormat); + Assert.AreEqual("https://test-resource.openai.azure.com/openai/{0}", api.Settings.Info.BaseRequestUrlFormat); } [Test] @@ -199,6 +199,7 @@ public void Test_12_CustomDomainConfigurationSettings() var api = new OpenAIClient(auth, settings); Debug.Log(api.Settings.Info.BaseRequest); Debug.Log(api.Settings.Info.BaseRequestUrlFormat); + Assert.AreEqual("https://api.your-custom-domain.com/v1/{0}", api.Settings.Info.BaseRequestUrlFormat); } [TearDown] diff --git a/OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_02_Tools.cs b/OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_02_Extensions.cs similarity index 92% rename from OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_02_Tools.cs rename to OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_02_Extensions.cs index 1b9e683a..fad7bc1d 100644 --- a/OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_02_Tools.cs +++ b/OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_02_Extensions.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq; using NUnit.Framework; using OpenAI.Images; +using OpenAI.Tests.StructuredOutput; using OpenAI.Tests.Weather; using System; using System.Collections.Generic; @@ -13,10 +14,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); @@ -28,7 +29,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 { @@ -40,7 +41,6 @@ public async Task Test_02_Tool_Funcs() Tool.FromFunc("test_no_specifiers", (string arg1) => arg1) }; - try { var json = JsonConvert.SerializeObject(tools, Formatting.Indented, OpenAIClient.JsonSerializationOptions); @@ -105,5 +105,12 @@ private string FunctionWithArrayArgs(List list) { return JsonConvert.SerializeObject(new { list }, OpenAIClient.JsonSerializationOptions); } + + [Test] + public void Test_02_01_GenerateJsonSchema() + { + JsonSchema mathSchema = typeof(MathResponse); + Debug.Log(mathSchema.ToString()); + } } } diff --git a/OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_02_Tools.cs.meta b/OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_02_Extensions.cs.meta similarity index 100% rename from OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_02_Tools.cs.meta rename to OpenAI/Packages/com.openai.unity/Tests/TestFixture_00_02_Extensions.cs.meta diff --git a/OpenAI/Packages/com.openai.unity/Tests/TestFixture_03_Threads.cs b/OpenAI/Packages/com.openai.unity/Tests/TestFixture_03_Threads.cs index a27ccba0..bbd1312d 100644 --- a/OpenAI/Packages/com.openai.unity/Tests/TestFixture_03_Threads.cs +++ b/OpenAI/Packages/com.openai.unity/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; @@ -720,58 +721,66 @@ 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 { - Debug.Log(@event.ToJsonString()); - await Task.CompletedTask; - }); + switch (@event) + { + case MessageResponse message: + if (message.Status != MessageStatus.Completed) + { + Debug.Log(@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); + Debug.Log($"Step {i}: {step.Explanation}"); + Assert.IsNotNull(step.Output); + Debug.Log($"Result: {step.Output}"); + } + + Assert.IsNotNull(mathResponse.FinalAnswer); + Debug.Log($"Final Answer: {mathResponse.FinalAnswer}"); + break; + default: + Debug.Log(@event.ToJsonString()); + break; + } + } + catch (Exception e) + { + Debug.Log(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); @@ -788,6 +797,7 @@ public async Task Test_05_01_CreateThreadAndRun_StructuredOutputs_Streaming() catch (Exception e) { Debug.LogException(e); + throw; } finally { diff --git a/OpenAI/Packages/com.openai.unity/Tests/TestFixture_04_Chat.cs b/OpenAI/Packages/com.openai.unity/Tests/TestFixture_04_Chat.cs index 595d17fa..e6f90359 100644 --- a/OpenAI/Packages/com.openai.unity/Tests/TestFixture_04_Chat.cs +++ b/OpenAI/Packages/com.openai.unity/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; @@ -19,6 +20,7 @@ internal class TestFixture_04_Chat : AbstractTestFixture public async Task Test_01_01_GetChatCompletion() { Assert.IsNotNull(OpenAIClient.ChatEndpoint); + var messages = new List { new(Role.System, "You are a helpful assistant."), @@ -26,6 +28,7 @@ public async Task Test_01_01_GetChatCompletion() new(Role.Assistant, "The Los Angeles Dodgers won the World Series in 2020."), new(Role.User, "Where was it played?"), }; + var chatRequest = new ChatRequest(messages, Model.GPT4o); var response = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); Assert.IsNotNull(response); @@ -44,6 +47,7 @@ public async Task Test_01_01_GetChatCompletion() public async Task Test_01_02_GetChatStreamingCompletion() { Assert.IsNotNull(OpenAIClient.ChatEndpoint); + var messages = new List { new(Role.System, "You are a helpful assistant."), @@ -51,12 +55,19 @@ public async Task Test_01_02_GetChatStreamingCompletion() new(Role.Assistant, "The Los Angeles Dodgers won the World Series in 2020."), new(Role.User, "Where was it played?") }; + var chatRequest = new ChatRequest(messages); var cumulativeDelta = string.Empty; + var response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => { Assert.IsNotNull(partialResponse); - if (partialResponse.Usage != null) { return; } + + if (partialResponse.Usage != null) + { + return; + } + Assert.NotNull(partialResponse.Choices); Assert.NotZero(partialResponse.Choices.Count); @@ -65,6 +76,7 @@ public async Task Test_01_02_GetChatStreamingCompletion() cumulativeDelta += choice.Delta.Content; } }, true); + Assert.IsNotNull(response); Assert.IsNotNull(response.Choices); var choice = response.FirstChoice; @@ -82,11 +94,13 @@ public async Task Test_01_02_GetChatStreamingCompletion() public async Task Test_01_03_JsonMode() { Assert.IsNotNull(OpenAIClient.ChatEndpoint); + var messages = new List { new(Role.System, "You are a helpful assistant designed to output JSON."), new(Role.User, "Who won the world series in 2020?"), }; + var chatRequest = new ChatRequest(messages, Model.GPT4o, responseFormat: ChatResponseFormat.Json); var response = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); Assert.IsNotNull(response); @@ -121,6 +135,7 @@ public async Task Test_02_01_GetChatToolCompletion() { Tool.GetOrCreateTool(typeof(WeatherService), nameof(WeatherService.GetCurrentWeatherAsync)) }; + var chatRequest = new ChatRequest(messages, tools: tools, toolChoice: "none"); var response = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); Assert.IsNotNull(response); @@ -176,6 +191,7 @@ public async Task Test_02_01_GetChatToolCompletion() public async Task Test_02_02_GetChatToolCompletion_Streaming() { Assert.IsNotNull(OpenAIClient.ChatEndpoint); + var messages = new List { new(Role.System, "You are a helpful weather assistant. Always prompt the user for their location."), @@ -191,14 +207,22 @@ public async Task Test_02_02_GetChatToolCompletion_Streaming() { Tool.GetOrCreateTool(typeof(WeatherService), nameof(WeatherService.GetCurrentWeatherAsync)) }; + var chatRequest = new ChatRequest(messages, tools: tools, toolChoice: "none"); + var response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => { Assert.IsNotNull(partialResponse); - if (partialResponse.Usage != null) { return; } + + if (partialResponse.Usage != null) + { + return; + } + Assert.NotNull(partialResponse.Choices); Assert.NotZero(partialResponse.Choices.Count); }, true); + Assert.IsNotNull(response); Assert.IsNotNull(response.Choices); Assert.IsTrue(response.Choices.Count == 1); @@ -208,13 +232,20 @@ public async Task Test_02_02_GetChatToolCompletion_Streaming() messages.Add(locationMessage); Debug.Log($"{locationMessage.Role}: {locationMessage.Content}"); chatRequest = new ChatRequest(messages, tools: tools, toolChoice: "auto"); + response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => { Assert.IsNotNull(partialResponse); - if (partialResponse.Usage != null) { return; } + + if (partialResponse.Usage != null) + { + return; + } + Assert.NotNull(partialResponse.Choices); Assert.NotZero(partialResponse.Choices.Count); }, true); + Assert.IsNotNull(response); Assert.IsNotNull(response.Choices); Assert.IsTrue(response.Choices.Count == 1); @@ -228,13 +259,20 @@ public async Task Test_02_02_GetChatToolCompletion_Streaming() messages.Add(unitMessage); Debug.Log($"{unitMessage.Role}: {unitMessage.Content}"); chatRequest = new ChatRequest(messages, tools: tools, toolChoice: "auto"); + response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => { Assert.IsNotNull(partialResponse); - if (partialResponse.Usage != null) { return; } + + if (partialResponse.Usage != null) + { + return; + } + Assert.NotNull(partialResponse.Choices); Assert.NotZero(partialResponse.Choices.Count); }, true); + Assert.IsNotNull(response); Assert.IsNotNull(response.Choices); Assert.IsTrue(response.Choices.Count == 1); @@ -253,13 +291,20 @@ public async Task Test_02_02_GetChatToolCompletion_Streaming() Debug.Log($"{Role.Tool}: {functionResult}"); chatRequest = new ChatRequest(messages, tools: tools, toolChoice: "none"); + response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => { Assert.IsNotNull(partialResponse); - if (partialResponse.Usage != null) { return; } + + if (partialResponse.Usage != null) + { + return; + } + Assert.NotNull(partialResponse.Choices); Assert.NotZero(partialResponse.Choices.Count); }, true); + Assert.IsNotNull(response); } @@ -267,6 +312,7 @@ public async Task Test_02_02_GetChatToolCompletion_Streaming() public async Task Test_02_03_ChatCompletion_Multiple_Tools_Streaming() { Assert.IsNotNull(OpenAIClient.ChatEndpoint); + var messages = new List { new(Role.System, "You are a helpful weather assistant. Use the appropriate unit based on geographical location."), @@ -275,10 +321,16 @@ 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", parallelToolCalls: true); + var response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => { Assert.IsNotNull(partialResponse); - if (partialResponse.Usage != null) { return; } + + if (partialResponse.Usage != null) + { + return; + } + Assert.NotNull(partialResponse.Choices); Assert.NotZero(partialResponse.Choices.Count); }, true); @@ -307,6 +359,7 @@ public async Task Test_02_03_ChatCompletion_Multiple_Tools_Streaming() public async Task Test_02_04_GetChatToolForceCompletion() { Assert.IsNotNull(OpenAIClient.ChatEndpoint); + var messages = new List { new(Role.System, "You are a helpful weather assistant. Use the appropriate unit based on geographical location."), @@ -332,10 +385,12 @@ public async Task Test_02_04_GetChatToolForceCompletion() var locationMessage = new Message(Role.User, "I'm in New York, USA"); messages.Add(locationMessage); Debug.Log($"{locationMessage.Role}: {locationMessage.Content}"); + chatRequest = new ChatRequest( messages, tools: tools, toolChoice: nameof(WeatherService.GetCurrentWeatherAsync)); + response = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); Assert.IsNotNull(response); @@ -360,6 +415,7 @@ public async Task Test_02_04_GetChatToolForceCompletion() public async Task Test_03_01_GetChatVision() { Assert.IsNotNull(OpenAIClient.ChatEndpoint); + var messages = new List { new(Role.System, "You are a helpful assistant."), @@ -369,6 +425,7 @@ public async Task Test_03_01_GetChatVision() new ImageUrl("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", ImageDetail.Low) }) }; + var chatRequest = new ChatRequest(messages, model: Model.GPT4o); var response = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); Assert.IsNotNull(response); @@ -381,6 +438,7 @@ public async Task Test_03_01_GetChatVision() public async Task Test_03_02_GetChatVisionStreaming() { Assert.IsNotNull(OpenAIClient.ChatEndpoint); + var messages = new List { new(Role.System, "You are a helpful assistant."), @@ -390,14 +448,22 @@ public async Task Test_03_02_GetChatVisionStreaming() new ImageUrl("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", ImageDetail.Low) }) }; + var chatRequest = new ChatRequest(messages, model: Model.GPT4o); + var response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => { Assert.IsNotNull(partialResponse); - if (partialResponse.Usage != null) { return; } + + if (partialResponse.Usage != null) + { + return; + } + Assert.NotNull(partialResponse.Choices); Assert.NotZero(partialResponse.Choices.Count); }, true); + Assert.IsNotNull(response); Assert.IsNotNull(response.Choices); Debug.Log($"{response.FirstChoice.Message.Role}: {response.FirstChoice} | Finish Reason: {response.FirstChoice.FinishDetails}"); @@ -410,6 +476,7 @@ public async Task Test_03_03_GetChatVision_Texture() Assert.IsNotNull(OpenAIClient.ChatEndpoint); var imageAssetPath = AssetDatabase.GUIDToAssetPath("230fd778637d3d84d81355c8c13b1999"); var image = AssetDatabase.LoadAssetAtPath(imageAssetPath); + var messages = new List { new(Role.System, "You are a helpful assistant."), @@ -419,6 +486,7 @@ public async Task Test_03_03_GetChatVision_Texture() image }) }; + var chatRequest = new ChatRequest(messages, model: Model.GPT4o); var response = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); Assert.IsNotNull(response); @@ -431,6 +499,7 @@ public async Task Test_03_03_GetChatVision_Texture() public async Task Test_04_01_GetChatLogProbs() { Assert.IsNotNull(OpenAIClient.ChatEndpoint); + var messages = new List { new(Role.System, "You are a helpful assistant."), @@ -438,6 +507,7 @@ public async Task Test_04_01_GetChatLogProbs() new(Role.Assistant, "The Los Angeles Dodgers won the World Series in 2020."), new(Role.User, "Where was it played?"), }; + var chatRequest = new ChatRequest(messages, topLogProbs: 1); var response = await OpenAIClient.ChatEndpoint.GetCompletionAsync(chatRequest); Assert.IsNotNull(response); @@ -456,6 +526,7 @@ public async Task Test_04_01_GetChatLogProbs() public async Task Test_04_02_GetChatLogProbsStreaming() { Assert.IsNotNull(OpenAIClient.ChatEndpoint); + var messages = new List { new(Role.System, "You are a helpful assistant."), @@ -463,12 +534,19 @@ public async Task Test_04_02_GetChatLogProbsStreaming() new(Role.Assistant, "The Los Angeles Dodgers won the World Series in 2020."), new(Role.User, "Where was it played?"), }; + var chatRequest = new ChatRequest(messages, topLogProbs: 1); var cumulativeDelta = string.Empty; + var response = await OpenAIClient.ChatEndpoint.StreamCompletionAsync(chatRequest, partialResponse => { Assert.IsNotNull(partialResponse); - if (partialResponse.Usage != null) { return; } + + if (partialResponse.Usage != null) + { + return; + } + Assert.NotNull(partialResponse.Choices); Assert.NotZero(partialResponse.Choices.Count); @@ -477,6 +555,7 @@ public async Task Test_04_02_GetChatLogProbsStreaming() cumulativeDelta += choice.Delta.Content; } }, true); + Assert.IsNotNull(response); Assert.IsNotNull(response.Choices); var choice = response.FirstChoice; @@ -502,51 +581,27 @@ public async Task Test_06_01_GetChat_JsonSchema() 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) - { - Debug.Log($"[{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); + Debug.Log($"Step {i}: {step.Explanation}"); + Assert.IsNotNull(step.Output); + Debug.Log($"Result: {step.Output}"); } - response.GetUsage(); + Assert.IsNotNull(mathResponse.FinalAnswer); + Debug.Log($"Final Answer: {mathResponse.FinalAnswer}"); + + chatResponse.GetUsage(); } [Test] @@ -560,45 +615,18 @@ public async Task Test_06_01_GetChat_JsonSchema_Streaming() 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; } + + if (partialResponse.Usage != null) + { + return; + } + Assert.NotNull(partialResponse.Choices); Assert.NotZero(partialResponse.Choices.Count); @@ -607,17 +635,30 @@ 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())); - Debug.Log($"[{choice.Index}] {choice.Message.Role}: {choice} | Finish Reason: {choice.FinishReason}"); Assert.IsTrue(choice.Message.Role == Role.Assistant); Assert.IsTrue(choice.Message.Content!.Equals(cumulativeDelta)); - Debug.Log(response.ToString()); - response.GetUsage(); + + for (var i = 0; i < mathResponse.Steps.Count; i++) + { + var step = mathResponse.Steps[i]; + Assert.IsNotNull(step.Explanation); + Debug.Log($"Step {i}: {step.Explanation}"); + Assert.IsNotNull(step.Output); + Debug.Log($"Result: {step.Output}"); + } + + Assert.IsNotNull(mathResponse.FinalAnswer); + Debug.Log($"Final Answer: {mathResponse.FinalAnswer}"); + + chatResponse.GetUsage(); } } } diff --git a/OpenAI/Packages/com.openai.unity/Tests/Weather/DateTimeUtility.cs b/OpenAI/Packages/com.openai.unity/Tests/Weather/DateTimeUtility.cs new file mode 100644 index 00000000..790cfe43 --- /dev/null +++ b/OpenAI/Packages/com.openai.unity/Tests/Weather/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/Packages/com.openai.unity/Tests/Weather/DateTimeUtility.cs.meta b/OpenAI/Packages/com.openai.unity/Tests/Weather/DateTimeUtility.cs.meta new file mode 100644 index 00000000..afa748e1 --- /dev/null +++ b/OpenAI/Packages/com.openai.unity/Tests/Weather/DateTimeUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6678b3251ca40544985567af1015ed22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/OpenAI/Packages/com.openai.unity/Tests/Weather/MathResponse.cs b/OpenAI/Packages/com.openai.unity/Tests/Weather/MathResponse.cs new file mode 100644 index 00000000..a07939e6 --- /dev/null +++ b/OpenAI/Packages/com.openai.unity/Tests/Weather/MathResponse.cs @@ -0,0 +1,25 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace OpenAI.Tests.StructuredOutput +{ + internal sealed class MathResponse + { + [JsonProperty("steps")] + public IReadOnlyList Steps { get; private set; } + + [JsonProperty("final_answer")] + public string FinalAnswer { get; private set; } + } + + internal sealed class MathStep + { + [JsonProperty("explanation")] + public string Explanation { get; private set; } + + [JsonProperty("output")] + public string Output { get; private set; } + } +} diff --git a/OpenAI/Packages/com.openai.unity/Tests/Weather/MathResponse.cs.meta b/OpenAI/Packages/com.openai.unity/Tests/Weather/MathResponse.cs.meta new file mode 100644 index 00000000..200b48ae --- /dev/null +++ b/OpenAI/Packages/com.openai.unity/Tests/Weather/MathResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b5821ef08027267498362a1eeab98084 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/OpenAI/Packages/com.openai.unity/Tests/Weather/WeatherService.cs b/OpenAI/Packages/com.openai.unity/Tests/Weather/WeatherService.cs index 756d04c0..30f2c9c1 100644 --- a/OpenAI/Packages/com.openai.unity/Tests/Weather/WeatherService.cs +++ b/OpenAI/Packages/com.openai.unity/Tests/Weather/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/README.md b/README.md index 5b700669..9541442f 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ The recommended installation method is though the unity package manager and [Ope - [Streaming](#chat-streaming) - [Tools](#chat-tools) - [Vision](#chat-vision) - - [Json Schema](#chat-json-schema) :new: + - [Structured Outputs](#chat-structured-outputs) :new: - [Json Mode](#chat-json-mode) - [Audio](#audio) - [Create Speech](#create-speech) @@ -814,62 +814,86 @@ 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( + [JsonProperty("steps")] + public IReadOnlyList Steps { get; private set; } + + [JsonProperty("final_answer")] + public string FinalAnswer { get; private set; } +} + +public class MathStep +{ + [JsonProperty("explanation")] + public string Explanation { get; private set; } + + [JsonProperty("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 { - Debug.Log(@event.ToJsonString()); - await Task.CompletedTask; - }); + switch (@event) + { + case MessageResponse message: + if (message.Status != MessageStatus.Completed) + { + Debug.Log(@event.ToJsonString()); + break; + } + + var mathResponse = message.FromSchema(); + + for (var i = 0; i < mathResponse.Steps.Count; i++) + { + var step = mathResponse.Steps[i]; + Debug.Log($"Step {i}: {step.Explanation}"); + Debug.Log($"Result: {step.Output}"); + } + + Debug.Log($"Final Answer: {mathResponse.FinalAnswer}"); + break; + default: + Debug.Log(@event.ToJsonString()); + break; + } + } + catch (Exception e) + { + Debug.Log(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(); Debug.Log($"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)) { Debug.Log($"{response.Role}: {response.PrintContent()}"); } @@ -881,7 +905,6 @@ finally if (thread != null) { var isDeleted = await thread.DeleteAsync(deleteToolResources: true); - Assert.IsTrue(isDeleted); } } ``` @@ -1251,7 +1274,7 @@ var result = await api.ChatEndpoint.GetCompletionAsync(chatRequest); Debug.Log($"{result.FirstChoice.Message.Role}: {result.FirstChoice} | Finish Reason: {result.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. @@ -1260,6 +1283,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 { @@ -1267,48 +1319,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++) { - Debug.Log($"[{choice.Index}] {choice.Message.Role}: {choice} | Finish Reason: {choice.FinishReason}"); + var step = mathResponse.Steps[i]; + Debug.Log($"Step {i}: {step.Explanation}"); + Debug.Log($"Result: {step.Output}"); } -response.GetUsage(); +Debug.Log($"Final Answer: {mathResponse.FinalAnswer}"); +chatResponse.GetUsage(); ``` #### [Chat Json Mode](https://platform.openai.com/docs/guides/text-generation/json-mode)