From f6f0a8655298e02dbf73a322f9bc80a384012633 Mon Sep 17 00:00:00 2001 From: Krzysztof Cwalina Date: Tue, 24 Jun 2025 13:07:31 -0700 Subject: [PATCH 1/2] added conversions from AIFunction to various OpenAI tools --- .../OpenAIChatClient.cs | 34 +++--- .../OpenAIClientExtensions.cs | 29 +++++ .../OpenAIRealtimeConversationClient.cs | 66 +++++++++++ .../OpenAIResponseChatClient.cs | 27 +++-- .../OpenAIAIFunctionConversionTests.cs | 109 ++++++++++++++++++ 5 files changed, 238 insertions(+), 27 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 40526b708c0..26ebccfc251 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -101,6 +101,23 @@ void IDisposable.Dispose() // Nothing to dispose. Implementation required for the IChatClient interface. } + /// Converts an Extensions function to an OpenAI chat tool. + internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction) + { + bool? strict = + aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && + strictObj is bool strictValue ? + strictValue : null; + + // Perform transformations making the schema legal per OpenAI restrictions + JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict); + + // Map to an intermediate model so that redundant properties are skipped. + var tool = JsonSerializer.Deserialize(jsonSchema, ChatClientJsonContext.Default.ChatToolJson)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, ChatClientJsonContext.Default.ChatToolJson)); + return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); + } + /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. private static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, ChatOptions? chatOptions, JsonSerializerOptions jsonOptions) { @@ -557,23 +574,6 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) return result; } - /// Converts an Extensions function to an OpenAI chat tool. - private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) - { - bool? strict = - aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; - - // Perform transformations making the schema legal per OpenAI restrictions - JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict); - - // Map to an intermediate model so that redundant properties are skipped. - var tool = JsonSerializer.Deserialize(jsonSchema, ChatClientJsonContext.Default.ChatToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, ChatClientJsonContext.Default.ChatToolJson)); - return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); - } - private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage) { var destination = new UsageDetails diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 3b55280f5cd..9b400806955 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -6,11 +6,13 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.Shared.Diagnostics; using OpenAI; using OpenAI.Assistants; using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Embeddings; +using OpenAI.RealtimeConversation; using OpenAI.Responses; #pragma warning disable S103 // Lines should not be too long @@ -142,6 +144,33 @@ public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioCl public static IEmbeddingGenerator> AsIEmbeddingGenerator(this EmbeddingClient embeddingClient, int? defaultModelDimensions = null) => new OpenAIEmbeddingGenerator(embeddingClient, defaultModelDimensions); + /// Converts an Extensions function to an OpenAI chat tool. + /// function to convert. + /// An OpenAI ChatTool representing the function. + public static ChatTool AsOpenAIChatTool(this AIFunction aiFunction) + { + _ = Throw.IfNull(aiFunction); + return OpenAIChatClient.ToOpenAIChatTool(aiFunction); + } + + /// Converts an Extensions function to an OpenAI response tool. + /// The function to convert. + /// An OpenAI ResponseTool representing the function. + public static ResponseTool AsOpenAIResponseTool(this AIFunction aiFunction) + { + _ = Throw.IfNull(aiFunction); + return OpenAIResponseChatClient.ToResponseTool(aiFunction); + } + + /// Converts an Extensions function to an OpenAI ConversationFunctionTool. + /// The function to convert. + /// A ConversationFunctionTool representing the function. + public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction aiFunction) + { + _ = Throw.IfNull(aiFunction); + return OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(aiFunction); + } + /// Gets the JSON schema to use from the function. internal static JsonElement GetSchema(AIFunction function, bool? strict) => strict is true ? diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs new file mode 100644 index 00000000000..91ef4ce3c2b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenAI.RealtimeConversation; + +#pragma warning disable S907 // "goto" statement should not be used +#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable S3604 // Member initializer values should not be redundant +#pragma warning disable SA1204 // Static elements should appear before instance elements + +namespace Microsoft.Extensions.AI; + +// this contains only tool conversion routines for now. +internal sealed partial class OpenAIRealtimeConversationClient +{ + public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunction aiFunction) + { + bool? strict = + aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && + strictObj is bool strictValue ? + strictValue : null; + + string jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict).GetRawText(); + + var toolSchema = JsonSerializer.Deserialize(jsonSchema, RealtimeConversationClientJsonContext.Default.ConversationFunctionToolJson)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(toolSchema, RealtimeConversationClientJsonContext.Default.ConversationFunctionToolJson)); + + var tool = new ConversationFunctionTool(aiFunction.Name) + { + Description = aiFunction.Description, + Parameters = functionParameters, + }; + return tool; + } + + /// Used to create the JSON payload for an OpenAI chat tool description. + private sealed class ConversationFunctionToolJson + { + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public HashSet Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + + [JsonPropertyName("additionalProperties")] + public bool AdditionalProperties { get; set; } + } + + /// Source-generated JSON type information. + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] + [JsonSerializable(typeof(ConversationFunctionToolJson))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(string[]))] + private sealed partial class RealtimeConversationClientJsonContext : JsonSerializerContext; +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index e722c6c0c9c..34996e8af69 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -324,6 +324,21 @@ void IDisposable.Dispose() // Nothing to dispose. Implementation required for the IChatClient interface. } + internal static ResponseTool ToResponseTool(AIFunction aiFunction) + { + bool strict = + aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && + strictObj is bool strictValue && + strictValue; + + JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict); + + var oaitool = JsonSerializer.Deserialize(jsonSchema, ResponseClientJsonContext.Default.ResponseToolJson)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(oaitool, ResponseClientJsonContext.Default.ResponseToolJson)); + ResponseTool rtool = ResponseTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); + return rtool; + } + /// Creates a from a . private static ChatRole ToChatRole(MessageRole? role) => role switch @@ -374,16 +389,8 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt switch (tool) { case AIFunction aiFunction: - bool strict = - aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && - strictObj is bool strictValue && - strictValue; - - JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict); - - var oaitool = JsonSerializer.Deserialize(jsonSchema, ResponseClientJsonContext.Default.ResponseToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(oaitool, ResponseClientJsonContext.Default.ResponseToolJson)); - result.Tools.Add(ResponseTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict)); + ResponseTool rtool = ToResponseTool(aiFunction); + result.Tools.Add(rtool); break; case HostedWebSearchTool: diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs new file mode 100644 index 00000000000..9a36bff8bfb --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenAI.Chat; +using OpenAI.RealtimeConversation; +using OpenAI.Responses; +using Xunit; + +#pragma warning disable S103 // Lines should not be too long + +namespace Microsoft.Extensions.AI; + +public class OpenAIAIFunctionConversionTests +{ + // Test implementation of AIFunction + private class TestAIFunction : AIFunction + { + public TestAIFunction(string name, string description, Dictionary jsonSchema) + { + Name = name; + Description = description; + JsonSchema = System.Text.Json.JsonSerializer.SerializeToElement(jsonSchema); + } + + public override string Name { get; } + public override string Description { get; } + public override JsonElement JsonSchema { get; } + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => await Task.FromResult(null); + } + + // Shared schema for all tests + private static readonly Dictionary _testFunctionSchema = new() + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["name"] = new Dictionary + { + ["type"] = "string", + ["description"] = "The name parameter" + } + }, + ["required"] = new[] { "name" } + }; + + // Helper method to validate function parameters match our schema + private static void ValidateSchemaParameters(BinaryData parameters) + { + Assert.NotNull(parameters); + + using var jsonDoc = JsonDocument.Parse(parameters); + var root = jsonDoc.RootElement; + + Assert.Equal("object", root.GetProperty("type").GetString()); + Assert.True(root.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("name", out var nameProperty)); + Assert.Equal("string", nameProperty.GetProperty("type").GetString()); + Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); + } + + [Fact] + public void AIFunctionToChatToolConversionWorks() + { + AIFunction aiFunction = new TestAIFunction( + "test_function", + "A test function for conversion", + _testFunctionSchema); + + ChatTool chatTool = aiFunction.AsOpenAIChatTool(); + + Assert.NotNull(chatTool); + Assert.Equal("test_function", chatTool.FunctionName); + Assert.Equal("A test function for conversion", chatTool.FunctionDescription); + ValidateSchemaParameters(chatTool.FunctionParameters); + } + + [Fact] + public void AIFunctionToResponseToolConversionWorks() + { + AIFunction aiFunction = new TestAIFunction( + "test_function", + "A test function for conversion", + _testFunctionSchema); + + ResponseTool responseTool = aiFunction.AsOpenAIResponseTool(); + + Assert.NotNull(responseTool); + } + + [Fact] + public void AIFunctionToConversationFunctionToolConversionWorks() + { + AIFunction aiFunction = new TestAIFunction( + "test_function", + "A test function for conversion", + _testFunctionSchema); + + ConversationFunctionTool conversationTool = aiFunction.AsOpenAIConversationFunctionTool(); + + Assert.NotNull(conversationTool); + Assert.Equal("test_function", conversationTool.Name); + Assert.Equal("A test function for conversion", conversationTool.Description); + ValidateSchemaParameters(conversationTool.Parameters); + } +} From 4bd3521b939b181988db2ef21b3a75f374d665b0 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 25 Jun 2025 23:25:56 -0400 Subject: [PATCH 2/2] Remove duplication of tool handling across OpenAI impls --- .../OpenAIAssistantChatClient.cs | 42 +++---- .../OpenAIChatClient.cs | 51 ++------ .../OpenAIClientExtensions.cs | 99 ++++++++++----- .../OpenAIJsonContext.cs | 19 +++ .../OpenAIRealtimeConversationClient.cs | 56 +-------- .../OpenAIResponseChatClient.cs | 50 ++------ .../OpenAIAIFunctionConversionTests.cs | 114 +++++++----------- 7 files changed, 171 insertions(+), 260 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs index 7a64a86721d..3547483da10 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs @@ -9,7 +9,6 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -30,7 +29,7 @@ namespace Microsoft.Extensions.AI; /// Represents an for an Azure.AI.Agents.Persistent . [Experimental("OPENAI001")] -internal sealed partial class OpenAIAssistantChatClient : IChatClient +internal sealed class OpenAIAssistantChatClient : IChatClient { /// The underlying . private readonly AssistantClient _client; @@ -197,9 +196,9 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { ruUpdate.Contents.Add( new FunctionCallContent( - JsonSerializer.Serialize([ru.Value.Id, toolCallId], AssistantJsonContext.Default.StringArray), + JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray), functionName, - JsonSerializer.Deserialize(rau.FunctionArguments, AssistantJsonContext.Default.IDictionaryStringObject)!)); + JsonSerializer.Deserialize(rau.FunctionArguments, OpenAIJsonContext.Default.IDictionaryStringObject)!)); } yield return ruUpdate; @@ -237,6 +236,19 @@ void IDisposable.Dispose() // nop } + /// Converts an Extensions function to an OpenAI assistants function tool. + internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction) + { + (BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); + + return new FunctionToolDefinition(aiFunction.Name) + { + Description = aiFunction.Description, + Parameters = parameters, + StrictParameterSchemaEnabled = strict, + }; + } + /// /// Creates the to use for the request and extracts any function result contents /// that need to be submitted as tool results. @@ -284,18 +296,7 @@ void IDisposable.Dispose() switch (tool) { case AIFunction aiFunction: - bool? strict = aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out var strictValue) && strictValue is bool strictBool ? - strictBool : - null; - - JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict); - - runOptions.ToolsOverride.Add(new FunctionToolDefinition(aiFunction.Name) - { - Description = aiFunction.Description, - Parameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AssistantJsonContext.Default.JsonElement)), - StrictParameterSchemaEnabled = strict, - }); + runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction)); break; case HostedCodeInterpreterTool: @@ -340,7 +341,7 @@ void IDisposable.Dispose() case ChatResponseFormatJson jsonFormat when OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema: runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat( jsonFormat.SchemaName, - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AssistantJsonContext.Default.JsonElement)), + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), jsonFormat.SchemaDescription); break; @@ -453,7 +454,7 @@ void AppendSystemInstructions(string? toAppend) string[]? runAndCallIDs; try { - runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, AssistantJsonContext.Default.StringArray); + runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, OpenAIJsonContext.Default.StringArray); } catch { @@ -476,9 +477,4 @@ void AppendSystemInstructions(string? toAppend) return runId; } - - [JsonSerializable(typeof(JsonElement))] - [JsonSerializable(typeof(string[]))] - [JsonSerializable(typeof(IDictionary))] - private sealed partial class AssistantJsonContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 26ebccfc251..abbcb0ed0ae 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -7,7 +7,6 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -23,7 +22,7 @@ namespace Microsoft.Extensions.AI; /// Represents an for an OpenAI or . -internal sealed partial class OpenAIChatClient : IChatClient +internal sealed class OpenAIChatClient : IChatClient { /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -104,18 +103,9 @@ void IDisposable.Dispose() /// Converts an Extensions function to an OpenAI chat tool. internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction) { - bool? strict = - aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; - - // Perform transformations making the schema legal per OpenAI restrictions - JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict); - - // Map to an intermediate model so that redundant properties are skipped. - var tool = JsonSerializer.Deserialize(jsonSchema, ChatClientJsonContext.Default.ChatToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, ChatClientJsonContext.Default.ChatToolJson)); - return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); + (BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); + + return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, parameters, strict); } /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. @@ -564,8 +554,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.ResponseFormat = OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ? OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes( - JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ChatClientJsonContext.Default.JsonElement)), + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), jsonFormat.SchemaDescription) : OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); } @@ -668,27 +657,11 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, ChatClientJsonContext.Default.IDictionaryStringObject)!); + argumentParser: static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, ChatClientJsonContext.Default.IDictionaryStringObject)!); - - /// Used to create the JSON payload for an OpenAI chat tool description. - private sealed class ChatToolJson - { - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public HashSet Required { get; set; } = []; - - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; - - [JsonPropertyName("additionalProperties")] - public bool AdditionalProperties { get; set; } - } + argumentParser: static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. private sealed class FunctionCallInfo @@ -697,14 +670,4 @@ private sealed class FunctionCallInfo public string? Name; public StringBuilder? Arguments; } - - /// Source-generated JSON type information. - [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true)] - [JsonSerializable(typeof(ChatToolJson))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(string[]))] - private sealed partial class ChatClientJsonContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 9b400806955..24fd93ccb65 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -2,10 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; using OpenAI; using OpenAI.Assistants; @@ -26,7 +28,7 @@ namespace Microsoft.Extensions.AI; public static class OpenAIClientExtensions { /// Key into AdditionalProperties used to store a strict option. - internal const string StrictKey = "strictJsonSchema"; + private const string StrictKey = "strictJsonSchema"; /// Gets the default OpenAI endpoint. internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); @@ -108,12 +110,14 @@ static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode /// Gets an for use with this . /// The client. /// An that can be used to converse via the . + /// is . public static IChatClient AsIChatClient(this ChatClient chatClient) => new OpenAIChatClient(chatClient); /// Gets an for use with this . /// The client. /// An that can be used to converse via the . + /// is . public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient) => new OpenAIResponseChatClient(responseClient); @@ -126,13 +130,17 @@ public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient /// property. If no thread ID is provided via either mechanism, a new thread will be created for the request. /// /// An instance configured to interact with the specified agent and thread. - [Experimental("OPENAI001")] + /// is . + /// is . + /// is empty or composed entirely of whitespace. + [Experimental("OPENAI001")] // AssistantClient itself is experimental with this ID public static IChatClient AsIChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) => new OpenAIAssistantChatClient(assistantClient, assistantId, threadId); /// Gets an for use with this . /// The client. /// An that can be used to transcribe audio via the . + /// is . [Experimental("MEAI001")] public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioClient) => new OpenAISpeechToTextClient(audioClient); @@ -141,39 +149,74 @@ public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioCl /// The client. /// The number of dimensions to generate in each embedding. /// An that can be used to generate embeddings via the . + /// is . public static IEmbeddingGenerator> AsIEmbeddingGenerator(this EmbeddingClient embeddingClient, int? defaultModelDimensions = null) => new OpenAIEmbeddingGenerator(embeddingClient, defaultModelDimensions); - /// Converts an Extensions function to an OpenAI chat tool. - /// function to convert. - /// An OpenAI ChatTool representing the function. - public static ChatTool AsOpenAIChatTool(this AIFunction aiFunction) + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ChatTool AsOpenAIChatTool(this AIFunction function) => + OpenAIChatClient.ToOpenAIChatTool(Throw.IfNull(function)); + + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + [Experimental("OPENAI001")] // AssistantClient itself is experimental with this ID + public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) => + OpenAIAssistantChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); + + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => + OpenAIResponseChatClient.ToResponseTool(Throw.IfNull(function)); + + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => + OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); + + /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. + internal static (BinaryData Parameters, bool? Strict) ToOpenAIFunctionParameters(AIFunction aiFunction) { - _ = Throw.IfNull(aiFunction); - return OpenAIChatClient.ToOpenAIChatTool(aiFunction); + // Extract any strict setting from AdditionalProperties. + bool? strict = + aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && + strictObj is bool strictValue ? + strictValue : null; + + // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting. + JsonElement jsonSchema = strict is true ? + StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) : + aiFunction.JsonSchema; + + // Roundtrip the schema through the ToolJson model type to remove extra properties + // and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData. + var tool = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext.Default.ToolJson)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson)); + + return (functionParameters, strict); } - /// Converts an Extensions function to an OpenAI response tool. - /// The function to convert. - /// An OpenAI ResponseTool representing the function. - public static ResponseTool AsOpenAIResponseTool(this AIFunction aiFunction) + /// Used to create the JSON payload for an OpenAI tool description. + internal sealed class ToolJson { - _ = Throw.IfNull(aiFunction); - return OpenAIResponseChatClient.ToResponseTool(aiFunction); - } + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; - /// Converts an Extensions function to an OpenAI ConversationFunctionTool. - /// The function to convert. - /// A ConversationFunctionTool representing the function. - public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction aiFunction) - { - _ = Throw.IfNull(aiFunction); - return OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(aiFunction); - } + [JsonPropertyName("required")] + public HashSet Required { get; set; } = []; - /// Gets the JSON schema to use from the function. - internal static JsonElement GetSchema(AIFunction function, bool? strict) => - strict is true ? - StrictSchemaTransformCache.GetOrCreateTransformedSchema(function) : - function.JsonSchema; + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + + [JsonPropertyName("additionalProperties")] + public bool AdditionalProperties { get; set; } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs new file mode 100644 index 00000000000..00b0089ddf7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Source-generated JSON type information for use by all OpenAI implementations. +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] +[JsonSerializable(typeof(OpenAIClientExtensions.ToolJson))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class OpenAIJsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs index 91ef4ce3c2b..892a9e9aa2a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs @@ -2,65 +2,21 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; -using System.Text.Json; -using System.Text.Json.Serialization; using OpenAI.RealtimeConversation; -#pragma warning disable S907 // "goto" statement should not be used -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -#pragma warning disable S3604 // Member initializer values should not be redundant -#pragma warning disable SA1204 // Static elements should appear before instance elements - namespace Microsoft.Extensions.AI; -// this contains only tool conversion routines for now. -internal sealed partial class OpenAIRealtimeConversationClient +/// Provides helpers for interacting with OpenAI Realtime. +internal sealed class OpenAIRealtimeConversationClient { public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunction aiFunction) { - bool? strict = - aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; - - string jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict).GetRawText(); - - var toolSchema = JsonSerializer.Deserialize(jsonSchema, RealtimeConversationClientJsonContext.Default.ConversationFunctionToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(toolSchema, RealtimeConversationClientJsonContext.Default.ConversationFunctionToolJson)); + (BinaryData parameters, _) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); - var tool = new ConversationFunctionTool(aiFunction.Name) + return new ConversationFunctionTool(aiFunction.Name) { Description = aiFunction.Description, - Parameters = functionParameters, + Parameters = parameters, }; - return tool; - } - - /// Used to create the JSON payload for an OpenAI chat tool description. - private sealed class ConversationFunctionToolJson - { - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public HashSet Required { get; set; } = []; - - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; - - [JsonPropertyName("additionalProperties")] - public bool AdditionalProperties { get; set; } } - - /// Source-generated JSON type information. - [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true)] - [JsonSerializable(typeof(ConversationFunctionToolJson))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(string[]))] - private sealed partial class RealtimeConversationClientJsonContext : JsonSerializerContext; -} \ No newline at end of file +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index 34996e8af69..a5f68e10365 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -8,7 +8,6 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -23,7 +22,7 @@ namespace Microsoft.Extensions.AI; /// Represents an for an . -internal sealed partial class OpenAIResponseChatClient : IChatClient +internal sealed class OpenAIResponseChatClient : IChatClient { /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -124,7 +123,7 @@ public async Task GetResponseAsync( functionCall.FunctionArguments.ToMemory(), functionCall.CallId, functionCall.FunctionName, - static json => JsonSerializer.Deserialize(json.Span, ResponseClientJsonContext.Default.IDictionaryStringObject)!); + static json => JsonSerializer.Deserialize(json.Span, OpenAIJsonContext.Default.IDictionaryStringObject)!); fcc.RawRepresentation = outputItem; message.Contents.Add(fcc); break; @@ -247,7 +246,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( callInfo.Arguments?.ToString() ?? string.Empty, callInfo.ResponseItem.CallId, callInfo.ResponseItem.FunctionName, - static json => JsonSerializer.Deserialize(json, ResponseClientJsonContext.Default.IDictionaryStringObject)!); + static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); lastMessageId = callInfo.ResponseItem.Id; lastRole = ChatRole.Assistant; @@ -326,17 +325,9 @@ void IDisposable.Dispose() internal static ResponseTool ToResponseTool(AIFunction aiFunction) { - bool strict = - aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && - strictObj is bool strictValue && - strictValue; + (BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); - JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict); - - var oaitool = JsonSerializer.Deserialize(jsonSchema, ResponseClientJsonContext.Default.ResponseToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(oaitool, ResponseClientJsonContext.Default.ResponseToolJson)); - ResponseTool rtool = ResponseTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); - return rtool; + return ResponseTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, parameters, strict ?? false); } /// Creates a from a . @@ -450,7 +441,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt TextFormat = OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ? ResponseTextFormat.CreateJsonSchemaFormat( jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ResponseClientJsonContext.Default.JsonElement)), + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), jsonFormat.SchemaDescription) : ResponseTextFormat.CreateJsonObjectFormat(), }; @@ -627,7 +618,7 @@ private static List ToOpenAIResponsesContent(IList ToOpenAIResponsesContent(IListUsed to create the JSON payload for an OpenAI chat tool description. - private sealed class ResponseToolJson - { - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public HashSet Required { get; set; } = []; - - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; - - [JsonPropertyName("additionalProperties")] - public bool AdditionalProperties { get; set; } - } - /// POCO representing function calling info. /// Used to concatenation information for a single function call from across multiple streaming updates. private sealed class FunctionCallInfo(FunctionCallResponseItem item) @@ -671,15 +646,4 @@ private sealed class FunctionCallInfo(FunctionCallResponseItem item) public readonly FunctionCallResponseItem ResponseItem = item; public StringBuilder? Arguments; } - - /// Source-generated JSON type information. - [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true)] - [JsonSerializable(typeof(ResponseToolJson))] - [JsonSerializable(typeof(JsonElement))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(string[]))] - private sealed partial class ResponseClientJsonContext : JsonSerializerContext; } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs index 9a36bff8bfb..f2f0c9d8a3f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs @@ -1,109 +1,79 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. + using System; -using System.Collections.Generic; +using System.ComponentModel; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; +using OpenAI.Assistants; using OpenAI.Chat; using OpenAI.RealtimeConversation; using OpenAI.Responses; using Xunit; -#pragma warning disable S103 // Lines should not be too long +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. namespace Microsoft.Extensions.AI; public class OpenAIAIFunctionConversionTests { - // Test implementation of AIFunction - private class TestAIFunction : AIFunction + private static readonly AIFunction _testFunction = AIFunctionFactory.Create( + ([Description("The name parameter")] string name) => name, + "test_function", + "A test function for conversion"); + + [Fact] + public void AsOpenAIChatTool_ProducesValidInstance() { - public TestAIFunction(string name, string description, Dictionary jsonSchema) - { - Name = name; - Description = description; - JsonSchema = System.Text.Json.JsonSerializer.SerializeToElement(jsonSchema); - } + ChatTool tool = _testFunction.AsOpenAIChatTool(); - public override string Name { get; } - public override string Description { get; } - public override JsonElement JsonSchema { get; } - protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => await Task.FromResult(null); + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.FunctionDescription); + ValidateSchemaParameters(tool.FunctionParameters); } - // Shared schema for all tests - private static readonly Dictionary _testFunctionSchema = new() - { - ["type"] = "object", - ["properties"] = new Dictionary - { - ["name"] = new Dictionary - { - ["type"] = "string", - ["description"] = "The name parameter" - } - }, - ["required"] = new[] { "name" } - }; - - // Helper method to validate function parameters match our schema - private static void ValidateSchemaParameters(BinaryData parameters) + [Fact] + public void AsOpenAIResponseTool_ProducesValidInstance() { - Assert.NotNull(parameters); - - using var jsonDoc = JsonDocument.Parse(parameters); - var root = jsonDoc.RootElement; + ResponseTool tool = _testFunction.AsOpenAIResponseTool(); - Assert.Equal("object", root.GetProperty("type").GetString()); - Assert.True(root.TryGetProperty("properties", out var properties)); - Assert.True(properties.TryGetProperty("name", out var nameProperty)); - Assert.Equal("string", nameProperty.GetProperty("type").GetString()); - Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); + Assert.NotNull(tool); } [Fact] - public void AIFunctionToChatToolConversionWorks() + public void AsOpenAIConversationFunctionTool_ProducesValidInstance() { - AIFunction aiFunction = new TestAIFunction( - "test_function", - "A test function for conversion", - _testFunctionSchema); + ConversationFunctionTool tool = _testFunction.AsOpenAIConversationFunctionTool(); - ChatTool chatTool = aiFunction.AsOpenAIChatTool(); - - Assert.NotNull(chatTool); - Assert.Equal("test_function", chatTool.FunctionName); - Assert.Equal("A test function for conversion", chatTool.FunctionDescription); - ValidateSchemaParameters(chatTool.FunctionParameters); + Assert.NotNull(tool); + Assert.Equal("test_function", tool.Name); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); } [Fact] - public void AIFunctionToResponseToolConversionWorks() + public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() { - AIFunction aiFunction = new TestAIFunction( - "test_function", - "A test function for conversion", - _testFunctionSchema); - - ResponseTool responseTool = aiFunction.AsOpenAIResponseTool(); + FunctionToolDefinition tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); - Assert.NotNull(responseTool); + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); } - [Fact] - public void AIFunctionToConversationFunctionToolConversionWorks() + /// Helper method to validate function parameters match our schema + private static void ValidateSchemaParameters(BinaryData parameters) { - AIFunction aiFunction = new TestAIFunction( - "test_function", - "A test function for conversion", - _testFunctionSchema); + Assert.NotNull(parameters); - ConversationFunctionTool conversationTool = aiFunction.AsOpenAIConversationFunctionTool(); + using var jsonDoc = JsonDocument.Parse(parameters); + var root = jsonDoc.RootElement; - Assert.NotNull(conversationTool); - Assert.Equal("test_function", conversationTool.Name); - Assert.Equal("A test function for conversion", conversationTool.Description); - ValidateSchemaParameters(conversationTool.Parameters); + Assert.Equal("object", root.GetProperty("type").GetString()); + Assert.True(root.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("name", out var nameProperty)); + Assert.Equal("string", nameProperty.GetProperty("type").GetString()); + Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); } }