From ec8d3ab35c42fbe429a2425375cc40293b87224a Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 29 Jan 2025 18:31:29 +0000 Subject: [PATCH 1/3] Move AIFunction parameter schematization from parameter level to function level. --- .../Functions/AIFunctionMetadata.cs | 45 ++- .../Functions/AIFunctionParameterMetadata.cs | 4 - .../AIFunctionReturnParameterMetadata.cs | 14 +- .../Utilities/AIJsonUtilities.Schema.cs | 288 +++++++----------- .../AzureAIInferenceChatClient.cs | 29 +- .../OllamaChatClient.cs | 28 +- .../OllamaFunctionToolParameters.cs | 2 +- .../OpenAIModelMapper.ChatCompletion.cs | 26 +- .../OpenAIRealtimeExtensions.cs | 28 +- .../Functions/AIFunctionFactory.cs | 17 +- .../Functions/AIFunctionMetadataTests.cs | 3 +- .../AIFunctionParameterMetadataTests.cs | 6 - .../AIFunctionReturnParameterMetadataTests.cs | 4 +- .../Utilities/AIJsonUtilitiesTests.cs | 21 +- .../OpenAISerializationTests.cs | 4 +- 15 files changed, 224 insertions(+), 295 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs index 528212c4b2b..18acd783ba6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs @@ -17,10 +17,13 @@ namespace Microsoft.Extensions.AI; public sealed class AIFunctionMetadata { /// The name of the function. - private string _name = string.Empty; + private readonly string _name = string.Empty; /// The description of the function. - private string _description = string.Empty; + private readonly string _description = string.Empty; + + /// The JSON schema describing the function and its input parameters. + private readonly JsonElement _schema = AIJsonUtilities.DefaultJsonSchema; /// The function's parameters. private IReadOnlyList _parameters = []; @@ -55,6 +58,7 @@ public AIFunctionMetadata(AIFunctionMetadata metadata) Parameters = metadata.Parameters; ReturnParameter = metadata.ReturnParameter; AdditionalProperties = metadata.AdditionalProperties; + Schema = metadata.Schema; } /// Gets the name of the function. @@ -100,6 +104,43 @@ public AIFunctionReturnParameterMetadata ReturnParameter init => _returnParameter = Throw.IfNull(value); } + /// Gets a JSON Schema describing the function and its input parameters. + /// + /// + /// When specified, declares a self-contained JSON schema document that describes the function and its input parameters. + /// A simple example of a JSON schema for a function that adds two numbers together is shown below: + /// + /// + /// { + /// "title" : "addNumbers", + /// "description": "A simple function that adds two numbers together.", + /// "type": "object", + /// "properties": { + /// "a" : { "type": "number" }, + /// "b" : { "type": "number", "default": 1 } + /// }, + /// "required" : ["a"] + /// } + /// + /// + /// The metadata present in the schema document plays an important role in guiding AI function invocation. + /// Functions should incorporate as much detail as possible. The arity of the "properties" keyword should + /// also match the length of the list. + /// + /// + /// When no schema is specified, consuming chat clients should assume the "{}" or "true" schema, indicating that any JSON input is admissible. + /// + /// + public JsonElement Schema + { + get => _schema; + init + { + AIJsonUtilities.ValidateSchemaDocument(value); + _schema = value; + } + } + /// Gets any additional properties associated with the function. public IReadOnlyDictionary AdditionalProperties { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs index b2e77f619db..d9e66010ffd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs @@ -37,7 +37,6 @@ public AIFunctionParameterMetadata(AIFunctionParameterMetadata metadata) DefaultValue = metadata.DefaultValue; IsRequired = metadata.IsRequired; ParameterType = metadata.ParameterType; - Schema = metadata.Schema; } /// Gets the name of the parameter. @@ -61,7 +60,4 @@ public string Name /// Gets the .NET type of the parameter. public Type? ParameterType { get; init; } - - /// Gets a JSON Schema describing the parameter's type. - public object? Schema { get; init; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs index e96e67d4806..9ca5b3b6e49 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Text.Json; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -14,6 +15,9 @@ public sealed class AIFunctionReturnParameterMetadata /// Gets an empty return parameter metadata instance. public static AIFunctionReturnParameterMetadata Empty { get; } = new(); + /// The JSON schema describing the function and its input parameters. + private readonly JsonElement _schema = AIJsonUtilities.DefaultJsonSchema; + /// Initializes a new instance of the class. public AIFunctionReturnParameterMetadata() { @@ -34,5 +38,13 @@ public AIFunctionReturnParameterMetadata(AIFunctionReturnParameterMetadata metad public Type? ParameterType { get; init; } /// Gets a JSON Schema describing the type of the return parameter. - public object? Schema { get; init; } + public JsonElement Schema + { + get => _schema; + init + { + AIJsonUtilities.ValidateSchemaDocument(value); + _schema = value; + } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs index b67fc1ddd77..ee2e497638a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Concurrent; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -25,100 +25,90 @@ namespace Microsoft.Extensions.AI; /// Provides a collection of utility methods for marshalling JSON data. public static partial class AIJsonUtilities { + private const string SchemaPropertyName = "$schema"; + private const string TitlePropertyName = "title"; + private const string DescriptionPropertyName = "description"; + private const string NotPropertyName = "not"; + private const string TypePropertyName = "type"; + private const string PatternPropertyName = "pattern"; + private const string EnumPropertyName = "enum"; + private const string PropertiesPropertyName = "properties"; + private const string RequiredPropertyName = "required"; + private const string AdditionalPropertiesPropertyName = "additionalProperties"; + private const string DefaultPropertyName = "default"; + private const string RefPropertyName = "$ref"; + /// The uri used when populating the $schema keyword in inferred schemas. private const string SchemaKeywordUri = "https://json-schema.org/draft/2020-12/schema"; - /// Soft limit for how many items should be stored in the dictionaries in . - private const int CacheSoftLimit = 4096; - - /// Caches of generated schemas for each that's employed. - private static readonly ConditionalWeakTable> _schemaCaches = new(); - - /// Gets a JSON schema accepting all values. - private static readonly JsonElement _trueJsonSchema = ParseJsonElement("true"u8); - - /// Gets a JSON schema only accepting null values. - private static readonly JsonElement _nullJsonSchema = ParseJsonElement("""{"type":"null"}"""u8); - // List of keywords used by JsonSchemaExporter but explicitly disallowed by some AI vendors. // cf. https://platform.openai.com/docs/guides/structured-outputs#some-type-specific-keywords-are-not-yet-supported private static readonly string[] _schemaKeywordsDisallowedByAIVendors = ["minLength", "maxLength", "pattern", "format"]; /// - /// Determines a JSON schema for the provided parameter metadata. + /// Determines a JSON schema for the provided AI function parameter metadata. /// - /// The parameter metadata from which to infer the schema. - /// The containing function metadata. + /// The title keyword used by the method schema. + /// The description keyword used by the method schema. + /// The AI function parameter metadata. /// The options used to extract the schema from the specified type. /// The options controlling schema inference. /// A JSON schema document encoded as a . - public static JsonElement ResolveParameterJsonSchema( - AIFunctionParameterMetadata parameterMetadata, - AIFunctionMetadata functionMetadata, + public static JsonElement CreateFunctionJsonSchema( + string? title = null, + string? description = null, + IReadOnlyList? parameters = null, JsonSerializerOptions? serializerOptions = null, AIJsonSchemaCreateOptions? inferenceOptions = null) { - _ = Throw.IfNull(parameterMetadata); - _ = Throw.IfNull(functionMetadata); + serializerOptions ??= DefaultOptions; + inferenceOptions ??= AIJsonSchemaCreateOptions.Default; - serializerOptions ??= functionMetadata.JsonSerializerOptions ?? DefaultOptions; + JsonObject parameterSchemas = new(); + JsonArray? requiredProperties = null; + foreach (AIFunctionParameterMetadata parameter in parameters ?? []) + { + JsonNode parameterSchema = CreateJsonSchemaCore( + parameter.ParameterType, + parameter.Name, + parameter.Description, + parameter.HasDefaultValue, + parameter.DefaultValue, + serializerOptions, + inferenceOptions); + + parameterSchemas.Add(parameter.Name, parameterSchema); + if (parameter.IsRequired) + { + (requiredProperties ??= []).Add((JsonNode)parameter.Name); + } + } - if (ReferenceEquals(serializerOptions, functionMetadata.JsonSerializerOptions) && - parameterMetadata.Schema is JsonElement schema) + JsonObject schema = new(); + if (inferenceOptions.IncludeSchemaKeyword) { - // If the resolved options matches that of the function metadata, - // we can just return the precomputed JSON schema value. - return schema; + schema[SchemaPropertyName] = SchemaKeywordUri; } - return CreateParameterJsonSchema( - parameterMetadata.ParameterType, - parameterMetadata.Name, - description: parameterMetadata.Description, - hasDefaultValue: parameterMetadata.HasDefaultValue, - defaultValue: parameterMetadata.DefaultValue, - serializerOptions, - inferenceOptions); - } + if (!string.IsNullOrWhiteSpace(title)) + { + schema[TitlePropertyName] = title; + } - /// - /// Creates a JSON schema for the provided parameter metadata. - /// - /// The type of the parameter. - /// The name of the parameter. - /// The description of the parameter. - /// if the parameter is optional; otherwise, . - /// The default value of the optional parameter, if applicable. - /// The options used to extract the schema from the specified type. - /// The options controlling schema inference. - /// A JSON schema document encoded as a . - /// - /// Uses a cache keyed on the to store schema result, - /// unless a delegate has been specified. - /// - public static JsonElement CreateParameterJsonSchema( - Type? type, - string parameterName, - string? description = null, - bool hasDefaultValue = false, - object? defaultValue = null, - JsonSerializerOptions? serializerOptions = null, - AIJsonSchemaCreateOptions? inferenceOptions = null) - { - _ = Throw.IfNull(parameterName); + if (!string.IsNullOrWhiteSpace(description)) + { + schema[DescriptionPropertyName] = description; + } - serializerOptions ??= DefaultOptions; - inferenceOptions ??= AIJsonSchemaCreateOptions.Default; + schema[TypePropertyName] = "object"; // Method schemas always hardcode the type as "object". + schema[PropertiesPropertyName] = parameterSchemas; - SchemaGenerationKey key = new( - type, - parameterName, - description, - hasDefaultValue, - defaultValue, - inferenceOptions); + if (requiredProperties is not null) + { + schema[RequiredPropertyName] = requiredProperties; + } - return GetJsonSchemaCached(serializerOptions, key); + return JsonSerializer.SerializeToElement(schema, JsonContext.Default.JsonNode); } /// Creates a JSON schema for the specified type. @@ -129,10 +119,6 @@ public static JsonElement CreateParameterJsonSchema( /// The options used to extract the schema from the specified type. /// The options controlling schema inference. /// A representing the schema. - /// - /// Uses a cache keyed on the to store schema result, - /// unless a delegate has been specified. - /// public static JsonElement CreateJsonSchema( Type? type, string? description = null, @@ -143,36 +129,20 @@ public static JsonElement CreateJsonSchema( { serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; - - SchemaGenerationKey key = new( - type, - parameterName: null, - description, - hasDefaultValue, - defaultValue, - inferenceOptions); - - return GetJsonSchemaCached(serializerOptions, key); + JsonNode schema = CreateJsonSchemaCore(type, parameterName: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions); + return JsonSerializer.SerializeToElement(schema, JsonContext.Default.JsonNode); } - private static JsonElement GetJsonSchemaCached(JsonSerializerOptions options, SchemaGenerationKey key) - { - options.MakeReadOnly(); - ConcurrentDictionary cache = _schemaCaches.GetOrCreateValue(options); + /// Gets the default JSON schema to be used by types or functions. + internal static JsonElement DefaultJsonSchema { get; } = ParseJsonElement("{}"u8); - if (key.TransformSchemaNode is not null || cache.Count >= CacheSoftLimit) + /// Validates the provided JSON schema document. + internal static void ValidateSchemaDocument(JsonElement document, [CallerArgumentExpression("document")] string? paramName = null) + { + if (document.ValueKind is not JsonValueKind.Object or JsonValueKind.False or JsonValueKind.True) { - return GetJsonSchemaCore(options, key); + Throw.ArgumentException(paramName ?? "schema", "The schema document must be an object or a boolean value."); } - - return cache.GetOrAdd( - key: key, -#if NET - valueFactory: static (key, options) => GetJsonSchemaCore(options, key), - factoryArgument: options); -#else - valueFactory: key => GetJsonSchemaCore(options, key)); -#endif } #if !NET9_0_OR_GREATER @@ -180,44 +150,48 @@ private static JsonElement GetJsonSchemaCached(JsonSerializerOptions options, Sc Justification = "Pre STJ-9 schema extraction can fail with a runtime exception if certain reflection metadata have been trimmed. " + "The exception message will guide users to turn off 'IlcTrimMetadata' which resolves all issues.")] #endif - private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, SchemaGenerationKey key) + private static JsonNode CreateJsonSchemaCore( + Type? type, + string? parameterName, + string? description, + bool hasDefaultValue, + object? defaultValue, + JsonSerializerOptions serializerOptions, + AIJsonSchemaCreateOptions inferenceOptions) { - _ = Throw.IfNull(options); - options.MakeReadOnly(); + serializerOptions.MakeReadOnly(); - if (key.Type is null) + if (type is null) { // For parameters without a type generate a rudimentary schema with available metadata. JsonObject? schemaObj = null; - if (key.IncludeSchemaKeyword) + if (inferenceOptions.IncludeSchemaKeyword) { - (schemaObj = [])["$schema"] = SchemaKeywordUri; + (schemaObj = [])[SchemaPropertyName] = SchemaKeywordUri; } - if (key.Description is not null) + if (description is not null) { - (schemaObj ??= [])["description"] = key.Description; + (schemaObj ??= [])[DescriptionPropertyName] = description; } - if (key.HasDefaultValue) + if (hasDefaultValue) { - JsonNode? defaultValueNode = key.DefaultValue is { } defaultValue - ? JsonSerializer.Serialize(defaultValue, options.GetTypeInfo(defaultValue.GetType())) + JsonNode? defaultValueNode = defaultValue is not null + ? JsonSerializer.Serialize(defaultValue, serializerOptions.GetTypeInfo(defaultValue.GetType())) : null; - (schemaObj ??= [])["default"] = defaultValueNode; + (schemaObj ??= [])[DefaultPropertyName] = defaultValueNode; } - return schemaObj is null - ? _trueJsonSchema - : JsonSerializer.SerializeToElement(schemaObj, JsonContext.Default.JsonNode); + return schemaObj ?? (JsonNode)true; } - if (key.Type == typeof(void)) + if (type == typeof(void)) { - return _nullJsonSchema; + return new JsonObject { [TypePropertyName] = null }; } JsonSchemaExporterOptions exporterOptions = new() @@ -226,23 +200,10 @@ private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, Sche TransformSchemaNode = TransformSchemaNode, }; - JsonNode node = options.GetJsonSchemaAsNode(key.Type, exporterOptions); - return JsonSerializer.SerializeToElement(node, JsonContext.Default.JsonNode); + return serializerOptions.GetJsonSchemaAsNode(type, exporterOptions); JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, JsonNode schema) { - const string SchemaPropertyName = "$schema"; - const string DescriptionPropertyName = "description"; - const string NotPropertyName = "not"; - const string TypePropertyName = "type"; - const string PatternPropertyName = "pattern"; - const string EnumPropertyName = "enum"; - const string PropertiesPropertyName = "properties"; - const string RequiredPropertyName = "required"; - const string AdditionalPropertiesPropertyName = "additionalProperties"; - const string DefaultPropertyName = "default"; - const string RefPropertyName = "$ref"; - AIJsonSchemaCreateContext ctx = new(schemaExporterContext); if (ctx.GetCustomAttribute() is { } attr) @@ -255,26 +216,26 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js // The resulting schema might be a $ref using a pointer to a different location in the document. // As JSON pointer doesn't support relative paths, parameter schemas need to fix up such paths // to accommodate the fact that they're being nested inside of a higher-level schema. - if (key.ParameterName is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName)) + if (parameterName is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName)) { // Fix up any $ref URIs to match the path from the root document. string refUri = paramName!.GetValue(); Debug.Assert(refUri is "#" || refUri.StartsWith("#/", StringComparison.Ordinal), $"Expected {nameof(refUri)} to be either # or start with #/, got {refUri}"); refUri = refUri == "#" - ? $"#/{PropertiesPropertyName}/{key.ParameterName}" - : $"#/{PropertiesPropertyName}/{key.ParameterName}/{refUri.AsMemory("#/".Length)}"; + ? $"#/{PropertiesPropertyName}/{parameterName}" + : $"#/{PropertiesPropertyName}/{parameterName}/{refUri.AsMemory("#/".Length)}"; objSchema[RefPropertyName] = (JsonNode)refUri; } // Include the type keyword in enum types - if (key.IncludeTypeInEnumSchemas && ctx.TypeInfo.Type.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) + if (inferenceOptions.IncludeTypeInEnumSchemas && ctx.TypeInfo.Type.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) { objSchema.InsertAtStart(TypePropertyName, "string"); } // Disallow additional properties in object schemas - if (key.DisallowAdditionalProperties && + if (inferenceOptions.DisallowAdditionalProperties && objSchema.ContainsKey(PropertiesPropertyName) && !objSchema.ContainsKey(AdditionalPropertiesPropertyName)) { @@ -282,7 +243,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js } // Mark all properties as required - if (key.RequireAllProperties && + if (inferenceOptions.RequireAllProperties && objSchema.TryGetPropertyValue(PropertiesPropertyName, out JsonNode? properties) && properties is JsonObject propertiesObj) { @@ -318,30 +279,29 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js { // We are at the root-level schema node, update/append parameter-specific metadata - if (!string.IsNullOrWhiteSpace(key.Description)) + if (!string.IsNullOrWhiteSpace(description)) { JsonObject obj = ConvertSchemaToObject(ref schema); - JsonNode descriptionNode = (JsonNode)key.Description!; int index = obj.IndexOf(DescriptionPropertyName); if (index < 0) { // If there's no description property, insert it at the beginning of the doc. - obj.InsertAtStart(DescriptionPropertyName, (JsonNode)key.Description!); + obj.InsertAtStart(DescriptionPropertyName, (JsonNode)description!); } else { // If there is a description property, just update it in-place. - obj[index] = (JsonNode)key.Description!; + obj[index] = (JsonNode)description!; } } - if (key.HasDefaultValue) + if (hasDefaultValue) { - JsonNode? defaultValue = JsonSerializer.Serialize(key.DefaultValue, options.GetTypeInfo(typeof(object))); - ConvertSchemaToObject(ref schema)[DefaultPropertyName] = defaultValue; + JsonNode? defaultValueNode = JsonSerializer.Serialize(defaultValue, serializerOptions.GetTypeInfo(typeof(object))); + ConvertSchemaToObject(ref schema)[DefaultPropertyName] = defaultValueNode; } - if (key.IncludeSchemaKeyword) + if (inferenceOptions.IncludeSchemaKeyword) { // The $schema property must be the first keyword in the object ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); @@ -349,7 +309,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js } // Finally, apply any user-defined transformations if specified. - if (key.TransformSchemaNode is { } transformer) + if (inferenceOptions.TransformSchemaNode is { } transformer) { schema = transformer(ctx, schema); } @@ -443,45 +403,9 @@ private static int IndexOf(this JsonObject jsonObject, string key) return -1; } #endif - private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) { Utf8JsonReader reader = new(utf8Json); return JsonElement.ParseValue(ref reader); } - - /// The equatable key used to look up cached schemas. - private readonly record struct SchemaGenerationKey - { - public SchemaGenerationKey( - Type? type, - string? parameterName, - string? description, - bool hasDefaultValue, - object? defaultValue, - AIJsonSchemaCreateOptions options) - { - Type = type; - ParameterName = parameterName; - Description = description; - HasDefaultValue = hasDefaultValue; - DefaultValue = defaultValue; - IncludeSchemaKeyword = options.IncludeSchemaKeyword; - DisallowAdditionalProperties = options.DisallowAdditionalProperties; - IncludeTypeInEnumSchemas = options.IncludeTypeInEnumSchemas; - RequireAllProperties = options.RequireAllProperties; - TransformSchemaNode = options.TransformSchemaNode; - } - - public Type? Type { get; } - public string? ParameterName { get; } - public string? Description { get; } - public bool HasDefaultValue { get; } - public object? DefaultValue { get; } - public bool IncludeSchemaKeyword { get; } - public bool DisallowAdditionalProperties { get; } - public bool IncludeTypeInEnumSchemas { get; } - public bool RequireAllProperties { get; } - public Func? TransformSchemaNode { get; } - } } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 85903beb701..7204372ff4f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -24,9 +24,6 @@ namespace Microsoft.Extensions.AI; /// Represents an for an Azure AI Inference . public sealed class AzureAIInferenceChatClient : IChatClient { - /// A default schema to use when a parameter lacks a pre-defined schema. - private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; - /// The underlying . private readonly ChatCompletionsClient _chatCompletionsClient; @@ -376,33 +373,19 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, /// Converts an Extensions function to an AzureAI chat tool. private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunction) { - BinaryData resultParameters = AzureAIChatToolJson.ZeroFunctionParametersSchema; - - var parameters = aiFunction.Metadata.Parameters; - if (parameters is { Count: > 0 }) + BinaryData functionParameters = AzureAIChatToolJson.ZeroFunctionParametersSchema; + if (aiFunction.Metadata.Schema is { } schema) { - AzureAIChatToolJson tool = new(); - - foreach (AIFunctionParameterMetadata parameter in parameters) - { - tool.Properties.Add( - parameter.Name, - parameter.Schema is JsonElement schema ? schema : _defaultParameterSchema); - - if (parameter.IsRequired) - { - tool.Required.Add(parameter.Name); - } - } - - resultParameters = BinaryData.FromBytes( + // Map to an intermediate model so that redundant properties are skipped. + AzureAIChatToolJson tool = JsonSerializer.Deserialize(schema, JsonContext.Default.AzureAIChatToolJson)!; + functionParameters = BinaryData.FromBytes( JsonSerializer.SerializeToUtf8Bytes(tool, JsonContext.Default.AzureAIChatToolJson)); } return new(new FunctionDefinition(aiFunction.Metadata.Name) { Description = aiFunction.Metadata.Description, - Parameters = resultParameters, + Parameters = functionParameters, }); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 46cbb7d7d11..4743996e7aa 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -22,7 +22,6 @@ namespace Microsoft.Extensions.AI; /// Represents an for Ollama. public sealed class OllamaChatClient : IChatClient { - private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; private static readonly JsonElement _schemalessJsonResponseFormatValue = JsonDocument.Parse("\"json\"").RootElement; /// The api/chat endpoint URI. @@ -466,20 +465,21 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe } } - private static OllamaTool ToOllamaTool(AIFunction function) => new() + private static OllamaTool ToOllamaTool(AIFunction function) { - Type = "function", - Function = new OllamaFunctionTool + OllamaFunctionToolParameters toolParameters = function.Metadata.Schema is { } schema + ? JsonSerializer.Deserialize(schema, JsonContext.Default.OllamaFunctionToolParameters)! + : new() { Properties = new Dictionary(), Required = [] }; + + return new() { - Name = function.Metadata.Name, - Description = function.Metadata.Description, - Parameters = new OllamaFunctionToolParameters + Type = "function", + Function = new OllamaFunctionTool { - Properties = function.Metadata.Parameters.ToDictionary( - p => p.Name, - p => p.Schema is JsonElement e ? e : _defaultParameterSchema), - Required = function.Metadata.Parameters.Where(p => p.IsRequired).Select(p => p.Name).ToList(), - }, - } - }; + Name = function.Metadata.Name, + Description = function.Metadata.Description, + Parameters = toolParameters, + } + }; + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs index 1e01d4d5d62..9fa7d0d2adc 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs @@ -10,5 +10,5 @@ internal sealed class OllamaFunctionToolParameters { public string Type { get; set; } = "object"; public required IDictionary Properties { get; set; } - public required IList Required { get; set; } + public IList? Required { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs index 0cc75c5f34f..197b6a23cb1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs @@ -363,7 +363,6 @@ private static AITool FromOpenAIChatTool(ChatTool chatTool) { parameters.Add(new(property.Key) { - Schema = property.Value, IsRequired = openAiChatTool.Required.Contains(property.Key), }); } @@ -373,6 +372,7 @@ private static AITool FromOpenAIChatTool(ChatTool chatTool) Description = chatTool.FunctionDescription, AdditionalProperties = additionalProperties, Parameters = parameters, + Schema = JsonSerializer.SerializeToElement(openAiChatTool, OpenAIJsonContext.Default.OpenAIChatToolJson), ReturnParameter = new() { Description = "Return parameter", @@ -398,28 +398,16 @@ private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) strictObj is bool strictValue ? strictValue : null; - BinaryData resultParameters = OpenAIChatToolJson.ZeroFunctionParametersSchema; - - var parameters = aiFunction.Metadata.Parameters; - if (parameters is { Count: > 0 }) + BinaryData functionParameters = OpenAIChatToolJson.ZeroFunctionParametersSchema; + if (aiFunction.Metadata.Schema is { } schema) { - OpenAIChatToolJson tool = new(); - - foreach (AIFunctionParameterMetadata parameter in parameters) - { - tool.Properties.Add(parameter.Name, parameter.Schema is JsonElement e ? e : _defaultParameterSchema); - - if (parameter.IsRequired) - { - _ = tool.Required.Add(parameter.Name); - } - } - - resultParameters = BinaryData.FromBytes( + // Map to an intermediate model so that redundant properties are skipped. + OpenAIChatToolJson tool = JsonSerializer.Deserialize(schema, OpenAIJsonContext.Default.OpenAIChatToolJson)!; + functionParameters = BinaryData.FromBytes( JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.OpenAIChatToolJson)); } - return ChatTool.CreateFunctionTool(aiFunction.Metadata.Name, aiFunction.Metadata.Description, resultParameters, strict); + return ChatTool.CreateFunctionTool(aiFunction.Metadata.Name, aiFunction.Metadata.Description, functionParameters, strict); } private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs index c47cfc52c21..2cfdc5373a5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; using OpenAI.RealtimeConversation; +using static Microsoft.Extensions.AI.OpenAIModelMappers; namespace Microsoft.Extensions.AI; @@ -18,8 +19,6 @@ namespace Microsoft.Extensions.AI; /// public static class OpenAIRealtimeExtensions { - private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; - /// /// Converts a into a so that /// it can be used with . @@ -29,22 +28,18 @@ public static ConversationFunctionTool ToConversationFunctionTool(this AIFunctio { _ = Throw.IfNull(aiFunction); - var parametersSchema = new ConversationFunctionToolParametersSchema + BinaryData functionParameters = OpenAIChatToolJson.ZeroFunctionParametersSchema; + if (aiFunction.Metadata.Schema is { } schema) { - Type = "object", - Properties = aiFunction.Metadata.Parameters - .ToDictionary(p => p.Name, GetParameterSchema), - Required = aiFunction.Metadata.Parameters - .Where(p => p.IsRequired) - .Select(p => p.Name), - }; + ConversationFunctionToolParametersSchema functionToolSchema = JsonSerializer.Deserialize(schema, OpenAIJsonContext.Default.ConversationFunctionToolParametersSchema)!; + functionParameters = new(JsonSerializer.SerializeToUtf8Bytes(functionToolSchema, OpenAIJsonContext.Default.ConversationFunctionToolParametersSchema)); + } return new ConversationFunctionTool { Name = aiFunction.Metadata.Name, Description = aiFunction.Metadata.Description, - Parameters = new BinaryData(JsonSerializer.SerializeToUtf8Bytes( - parametersSchema, OpenAIJsonContext.Default.ConversationFunctionToolParametersSchema)) + Parameters = functionParameters }; } @@ -95,15 +90,6 @@ public static async Task HandleToolCallsAsync( } } - private static JsonElement GetParameterSchema(AIFunctionParameterMetadata parameterMetadata) - { - return parameterMetadata switch - { - { Schema: JsonElement jsonElement } => jsonElement, - _ => _defaultParameterSchema, - }; - } - private static async Task GetFunctionCallOutputAsync( this ConversationItemStreamingFinishedUpdate update, IReadOnlyList tools, diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 854ec2f162a..a3084882d75 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -265,9 +265,10 @@ static bool IsAsyncMethod(MethodInfo method) Type returnType = GetReturnMarshaller(method, out _returnMarshaller); _returnTypeInfo = returnType != typeof(void) ? options.SerializerOptions.GetTypeInfo(returnType) : null; + string? description = options.Description ?? method.GetCustomAttribute(inherit: true)?.Description; Metadata = new AIFunctionMetadata(functionName) { - Description = options.Description ?? method.GetCustomAttribute(inherit: true)?.Description ?? string.Empty, + Description = description, Parameters = options.Parameters ?? parameterMetadata!, ReturnParameter = options.ReturnParameter ?? new() { @@ -277,6 +278,12 @@ static bool IsAsyncMethod(MethodInfo method) }, AdditionalProperties = options.AdditionalProperties ?? EmptyReadOnlyDictionary.Instance, JsonSerializerOptions = options.SerializerOptions, + Schema = AIJsonUtilities.CreateFunctionJsonSchema( + title: functionName, + description: description, + parameters: options.Parameters ?? parameterMetadata, + options.SerializerOptions, + options.SchemaCreateOptions) }; } @@ -419,14 +426,6 @@ static bool IsAsyncMethod(MethodInfo method) DefaultValue = parameter.HasDefaultValue ? parameter.DefaultValue : null, IsRequired = !parameter.IsOptional, ParameterType = parameter.ParameterType, - Schema = AIJsonUtilities.CreateParameterJsonSchema( - parameter.ParameterType, - parameter.Name, - description, - parameter.HasDefaultValue, - parameter.DefaultValue, - options.SerializerOptions, - options.SchemaCreateOptions) }; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionMetadataTests.cs index a1aa48bd115..08397144632 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionMetadataTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionMetadataTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Text.Json; using Xunit; namespace Microsoft.Extensions.AI; @@ -26,7 +27,7 @@ public void Constructor_String_PropsDefaulted() Assert.Empty(f.Parameters); Assert.NotNull(f.ReturnParameter); - Assert.Null(f.ReturnParameter.Schema); + Assert.True(JsonElement.DeepEquals(f.ReturnParameter.Schema, JsonDocument.Parse("{}").RootElement)); Assert.Null(f.ReturnParameter.ParameterType); Assert.Null(f.ReturnParameter.Description); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionParameterMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionParameterMetadataTests.cs index 23c33ecf07a..3eef60269a8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionParameterMetadataTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionParameterMetadataTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Text.Json; using Xunit; namespace Microsoft.Extensions.AI; @@ -26,7 +25,6 @@ public void Constructor_String_PropsDefaulted() Assert.Null(p.DefaultValue); Assert.False(p.IsRequired); Assert.Null(p.ParameterType); - Assert.Null(p.Schema); } [Fact] @@ -39,7 +37,6 @@ public void Constructor_Copy_PropsPropagated() DefaultValue = 42, IsRequired = true, ParameterType = typeof(int), - Schema = JsonDocument.Parse("""{"type":"integer"}"""), }; AIFunctionParameterMetadata p2 = new(p1); @@ -49,7 +46,6 @@ public void Constructor_Copy_PropsPropagated() Assert.Equal(p1.DefaultValue, p2.DefaultValue); Assert.Equal(p1.IsRequired, p2.IsRequired); Assert.Equal(p1.ParameterType, p2.ParameterType); - Assert.Equal(p1.Schema, p2.Schema); } [Fact] @@ -62,7 +58,6 @@ public void Constructor_Copy_PropsPropagatedAndOverwritten() DefaultValue = 42, IsRequired = true, ParameterType = typeof(int), - Schema = JsonDocument.Parse("""{"type":"integer"}"""), }; AIFunctionParameterMetadata p2 = new(p1) @@ -72,7 +67,6 @@ public void Constructor_Copy_PropsPropagatedAndOverwritten() DefaultValue = 43, IsRequired = false, ParameterType = typeof(long), - Schema = JsonDocument.Parse("""{"type":"number"}"""), }; Assert.Equal("description2", p2.Description); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionReturnParameterMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionReturnParameterMetadataTests.cs index bb5bbeec03a..d4721501093 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionReturnParameterMetadataTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionReturnParameterMetadataTests.cs @@ -14,7 +14,7 @@ public void Constructor_PropsDefaulted() AIFunctionReturnParameterMetadata p = new(); Assert.Null(p.Description); Assert.Null(p.ParameterType); - Assert.Null(p.Schema); + Assert.True(JsonElement.DeepEquals(p.Schema, JsonDocument.Parse("{}").RootElement)); } [Fact] @@ -24,7 +24,7 @@ public void Constructor_Copy_PropsPropagated() { Description = "description", ParameterType = typeof(int), - Schema = JsonDocument.Parse("""{"type":"integer"}"""), + Schema = JsonDocument.Parse("""{"type":"integer"}""").RootElement, }; AIFunctionReturnParameterMetadata p2 = new(p1); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index e79d2c9034e..a18e6fb5830 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -210,30 +210,32 @@ public class PocoWithTypesWithOpenAIUnsupportedKeywords } [Fact] - public static void ResolveParameterJsonSchema_ReturnsExpectedValue() + public static void CreateFunctionJsonSchema_ReturnsExpectedValue() { JsonSerializerOptions options = new(JsonSerializerOptions.Default); AIFunction func = AIFunctionFactory.Create((int x, int y) => x + y, serializerOptions: options); AIFunctionMetadata metadata = func.Metadata; AIFunctionParameterMetadata param = metadata.Parameters[0]; - JsonElement generatedSchema = Assert.IsType(param.Schema); - JsonElement resolvedSchema; - resolvedSchema = AIJsonUtilities.ResolveParameterJsonSchema(param, metadata, options); - Assert.True(JsonElement.DeepEquals(generatedSchema, resolvedSchema)); + JsonElement resolvedSchema = AIJsonUtilities.CreateFunctionJsonSchema(title: func.Metadata.Name, description: func.Metadata.Description, parameters: func.Metadata.Parameters); + Assert.True(JsonElement.DeepEquals(resolvedSchema, func.Metadata.Schema)); } [Fact] - public static void CreateParameterJsonSchema_TreatsIntegralTypesAsInteger_EvenWithAllowReadingFromString() + public static void CreateFunctionJsonSchema_TreatsIntegralTypesAsInteger_EvenWithAllowReadingFromString() { JsonSerializerOptions options = new(JsonSerializerOptions.Default) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; AIFunction func = AIFunctionFactory.Create((int a, int? b, long c, short d, float e, double f, decimal g) => { }, serializerOptions: options); AIFunctionMetadata metadata = func.Metadata; - foreach (var param in metadata.Parameters) + JsonElement schemaParameters = func.Metadata.Schema.GetProperty("properties"); + Assert.Equal(metadata.Parameters.Count, schemaParameters.GetPropertyCount()); + + int i = 0; + foreach (JsonProperty property in schemaParameters.EnumerateObject()) { - string numericType = Type.GetTypeCode(param.ParameterType) is TypeCode.Double or TypeCode.Single or TypeCode.Decimal + string numericType = Type.GetTypeCode(metadata.Parameters[i].ParameterType) is TypeCode.Double or TypeCode.Single or TypeCode.Decimal ? "number" : "integer"; @@ -243,8 +245,9 @@ public static void CreateParameterJsonSchema_TreatsIntegralTypesAsInteger_EvenWi } """).RootElement; - JsonElement actualSchema = Assert.IsType(param.Schema); + JsonElement actualSchema = property.Value; Assert.True(JsonElement.DeepEquals(expected, actualSchema)); + i++; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs index f636063804e..72461ce2a48 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -379,7 +380,8 @@ public static async Task RequestDeserialization_ToolCall() Assert.Equal("personName", parameter.Name); Assert.True(parameter.IsRequired); - JsonObject parameterSchema = Assert.IsType(JsonNode.Parse(Assert.IsType(parameter.Schema).GetRawText())); + JsonObject parametersSchema = Assert.IsType(JsonNode.Parse(function.Metadata.Schema.GetProperty("properties").GetRawText())); + var parameterSchema = Assert.IsType(Assert.Single(parametersSchema.Select(kvp => kvp.Value))); Assert.Equal(2, parameterSchema.Count); Assert.Equal("The person whose age is being requested", (string)parameterSchema["description"]!); Assert.Equal("string", (string)parameterSchema["type"]!); From 808c76203beae0753bd158730ef689713803b76c Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 4 Feb 2025 17:21:18 +0000 Subject: [PATCH 2/3] Address feedback --- .../Functions/AIFunctionMetadata.cs | 6 +++--- .../Functions/AIFunctionParameterMetadata.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs index 18acd783ba6..d6e5279d589 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs @@ -26,13 +26,13 @@ public sealed class AIFunctionMetadata private readonly JsonElement _schema = AIJsonUtilities.DefaultJsonSchema; /// The function's parameters. - private IReadOnlyList _parameters = []; + private readonly IReadOnlyList _parameters = []; /// The function's return parameter. - private AIFunctionReturnParameterMetadata _returnParameter = AIFunctionReturnParameterMetadata.Empty; + private readonly AIFunctionReturnParameterMetadata _returnParameter = AIFunctionReturnParameterMetadata.Empty; /// Optional additional properties in addition to the named properties already available on this class. - private IReadOnlyDictionary _additionalProperties = EmptyReadOnlyDictionary.Instance; + private readonly IReadOnlyDictionary _additionalProperties = EmptyReadOnlyDictionary.Instance; /// indexed by name, lazily initialized. private Dictionary? _parametersByName; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs index d9e66010ffd..372083fd799 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI; /// public sealed class AIFunctionParameterMetadata { - private string _name; + private readonly string _name; /// Initializes a new instance of the class for a parameter with the specified name. /// The name of the parameter. From 2551ce5bb938b5ece1fd3d1363aff45cf4cb96ba Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 4 Feb 2025 17:33:15 +0000 Subject: [PATCH 3/3] Address feedback. --- .../AzureAIChatToolJson.cs | 4 ---- .../AzureAIInferenceChatClient.cs | 12 +++--------- .../OllamaChatClient.cs | 6 +----- .../OpenAIModelMapper.ChatCompletion.cs | 15 +++------------ .../OpenAIRealtimeExtensions.cs | 10 ++-------- 5 files changed, 9 insertions(+), 38 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIChatToolJson.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIChatToolJson.cs index 77e675c0830..7be8fee287a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIChatToolJson.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIChatToolJson.cs @@ -1,7 +1,6 @@ // 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; @@ -11,9 +10,6 @@ namespace Microsoft.Extensions.AI; /// Used to create the JSON payload for an AzureAI chat tool description. internal sealed class AzureAIChatToolJson { - /// Gets a singleton JSON data for empty parameters. Optimization for the reasonably common case of a parameterless function. - public static BinaryData ZeroFunctionParametersSchema { get; } = new("""{"type":"object","required":[],"properties":{}}"""u8.ToArray()); - [JsonPropertyName("type")] public string Type { get; set; } = "object"; diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 7204372ff4f..511d9426c39 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -373,15 +373,9 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, /// Converts an Extensions function to an AzureAI chat tool. private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunction) { - BinaryData functionParameters = AzureAIChatToolJson.ZeroFunctionParametersSchema; - if (aiFunction.Metadata.Schema is { } schema) - { - // Map to an intermediate model so that redundant properties are skipped. - AzureAIChatToolJson tool = JsonSerializer.Deserialize(schema, JsonContext.Default.AzureAIChatToolJson)!; - functionParameters = BinaryData.FromBytes( - JsonSerializer.SerializeToUtf8Bytes(tool, JsonContext.Default.AzureAIChatToolJson)); - } - + // Map to an intermediate model so that redundant properties are skipped. + AzureAIChatToolJson tool = JsonSerializer.Deserialize(aiFunction.Metadata.Schema, JsonContext.Default.AzureAIChatToolJson)!; + BinaryData functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, JsonContext.Default.AzureAIChatToolJson)); return new(new FunctionDefinition(aiFunction.Metadata.Name) { Description = aiFunction.Metadata.Description, diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 4743996e7aa..4038ac8e761 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -467,10 +467,6 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe private static OllamaTool ToOllamaTool(AIFunction function) { - OllamaFunctionToolParameters toolParameters = function.Metadata.Schema is { } schema - ? JsonSerializer.Deserialize(schema, JsonContext.Default.OllamaFunctionToolParameters)! - : new() { Properties = new Dictionary(), Required = [] }; - return new() { Type = "function", @@ -478,7 +474,7 @@ private static OllamaTool ToOllamaTool(AIFunction function) { Name = function.Metadata.Name, Description = function.Metadata.Description, - Parameters = toolParameters, + Parameters = JsonSerializer.Deserialize(function.Metadata.Schema, JsonContext.Default.OllamaFunctionToolParameters)!, } }; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs index 197b6a23cb1..92e956da9e6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs @@ -398,15 +398,9 @@ private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) strictObj is bool strictValue ? strictValue : null; - BinaryData functionParameters = OpenAIChatToolJson.ZeroFunctionParametersSchema; - if (aiFunction.Metadata.Schema is { } schema) - { - // Map to an intermediate model so that redundant properties are skipped. - OpenAIChatToolJson tool = JsonSerializer.Deserialize(schema, OpenAIJsonContext.Default.OpenAIChatToolJson)!; - functionParameters = BinaryData.FromBytes( - JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.OpenAIChatToolJson)); - } - + // Map to an intermediate model so that redundant properties are skipped. + OpenAIChatToolJson tool = JsonSerializer.Deserialize(aiFunction.Metadata.Schema, OpenAIJsonContext.Default.OpenAIChatToolJson)!; + BinaryData functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.OpenAIChatToolJson)); return ChatTool.CreateFunctionTool(aiFunction.Metadata.Name, aiFunction.Metadata.Description, functionParameters, strict); } @@ -584,9 +578,6 @@ private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8 /// Used to create the JSON payload for an OpenAI chat tool description. public sealed class OpenAIChatToolJson { - /// Gets a singleton JSON data for empty parameters. Optimization for the reasonably common case of a parameterless function. - public static BinaryData ZeroFunctionParametersSchema { get; } = new("""{"type":"object","required":[],"properties":{}}"""u8.ToArray()); - [JsonPropertyName("type")] public string Type { get; set; } = "object"; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs index 2cfdc5373a5..db12baf962d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs @@ -10,7 +10,6 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; using OpenAI.RealtimeConversation; -using static Microsoft.Extensions.AI.OpenAIModelMappers; namespace Microsoft.Extensions.AI; @@ -28,13 +27,8 @@ public static ConversationFunctionTool ToConversationFunctionTool(this AIFunctio { _ = Throw.IfNull(aiFunction); - BinaryData functionParameters = OpenAIChatToolJson.ZeroFunctionParametersSchema; - if (aiFunction.Metadata.Schema is { } schema) - { - ConversationFunctionToolParametersSchema functionToolSchema = JsonSerializer.Deserialize(schema, OpenAIJsonContext.Default.ConversationFunctionToolParametersSchema)!; - functionParameters = new(JsonSerializer.SerializeToUtf8Bytes(functionToolSchema, OpenAIJsonContext.Default.ConversationFunctionToolParametersSchema)); - } - + ConversationFunctionToolParametersSchema functionToolSchema = JsonSerializer.Deserialize(aiFunction.Metadata.Schema, OpenAIJsonContext.Default.ConversationFunctionToolParametersSchema)!; + BinaryData functionParameters = new(JsonSerializer.SerializeToUtf8Bytes(functionToolSchema, OpenAIJsonContext.Default.ConversationFunctionToolParametersSchema)); return new ConversationFunctionTool { Name = aiFunction.Metadata.Name,