From 54ae79d9439cb8cc91d21f9fea694b2dd906bd15 Mon Sep 17 00:00:00 2001 From: Rolando Bosch Date: Thu, 5 Mar 2026 10:42:21 -0800 Subject: [PATCH] fix: prevent duplicate "null" in JSON Schema type arrays for nullable parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InsertNullTypeIfRequired() uses jsonArray.Contains(NullType) to check for existing "null" entries before adding one. JsonArray.Contains() compares JsonNode objects by reference equality — JsonNode does not override Equals(). The NullType constant ("null") creates a new JsonNode on implicit conversion, so the guard always fails and "null" is always added as a duplicate. This produces invalid schemas like ["string", "null", "null"] for Nullable parameters with = null defaults, causing HTTP 400 from OpenAI's strict-mode API. The fix replaces .Contains() with a value-based .Any() check, following the existing pattern used in NormalizeAdditionalProperties in the same file. Fixes microsoft/semantic-kernel#13527 Co-Authored-By: Claude Opus 4.6 --- .../Core/OpenAIFunctionTests.cs | 66 +++++++++++++++++++ .../Connectors.OpenAI/Core/OpenAIFunction.cs | 3 +- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIFunctionTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIFunctionTests.cs index 479a4759d750..76679b1b5a88 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIFunctionTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIFunctionTests.cs @@ -299,6 +299,72 @@ public void ItCleansUpRestrictedSchemaKeywords(string typeName, string keyword, } } + [Fact] + public void ItDoesNotInsertDuplicateNullInTypeArrayForOptionalParameter() + { + // Arrange — schema with type array already containing "null" (as AIJsonUtilities produces for Nullable) + var parameterSchema = KernelJsonSchema.Parse("""{"type":["string","null"],"description":"A nullable param"}"""); + OpenAIFunction f = KernelFunctionFactory.CreateFromMethod( + () => { }, + parameters: [new KernelParameterMetadata("param1") { Description = "A nullable param", IsRequired = false, Schema = parameterSchema }]).Metadata.ToOpenAIFunction(); + + // Act + ChatTool result = f.ToFunctionDefinition(strict: true); + ParametersData pd = JsonSerializer.Deserialize(result.FunctionParameters.ToString())!; + + // Assert + Assert.NotNull(pd.properties); + Assert.Single(pd.properties); + var expectedSchema = """{"type":["string","null"],"description":"A nullable param"}"""; + Assert.Equal( + JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)), + JsonSerializer.Serialize(pd.properties.First().Value.RootElement)); + } + + [Fact] + public void ItDoesNotInsertDuplicateNullInTypeArrayForNullableKeyword() + { + // Arrange — schema with "nullable": true and type array already containing "null" + var parameterSchema = KernelJsonSchema.Parse("""{"type":["string","null"],"nullable":true,"description":"A nullable param"}"""); + OpenAIFunction f = KernelFunctionFactory.CreateFromMethod( + () => { }, + parameters: [new KernelParameterMetadata("param1") { Description = "A nullable param", IsRequired = true, Schema = parameterSchema }]).Metadata.ToOpenAIFunction(); + + // Act + ChatTool result = f.ToFunctionDefinition(strict: true); + ParametersData pd = JsonSerializer.Deserialize(result.FunctionParameters.ToString())!; + + // Assert — "nullable" keyword is removed in strict mode, type array should not gain duplicate "null" + Assert.NotNull(pd.properties); + Assert.Single(pd.properties); + var expectedSchema = """{"type":["string","null"],"description":"A nullable param"}"""; + Assert.Equal( + JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)), + JsonSerializer.Serialize(pd.properties.First().Value.RootElement)); + } + + [Fact] + public void ItInsertsNullInTypeArrayWhenAbsent() + { + // Arrange — schema with type array that does NOT contain "null" + var parameterSchema = KernelJsonSchema.Parse("""{"type":["string"],"description":"An optional param"}"""); + OpenAIFunction f = KernelFunctionFactory.CreateFromMethod( + () => { }, + parameters: [new KernelParameterMetadata("param1") { Description = "An optional param", IsRequired = false, Schema = parameterSchema }]).Metadata.ToOpenAIFunction(); + + // Act + ChatTool result = f.ToFunctionDefinition(strict: true); + ParametersData pd = JsonSerializer.Deserialize(result.FunctionParameters.ToString())!; + + // Assert — "null" should be added to the type array + Assert.NotNull(pd.properties); + Assert.Single(pd.properties); + var expectedSchema = """{"type":["string","null"],"description":"An optional param"}"""; + Assert.Equal( + JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)), + JsonSerializer.Serialize(pd.properties.First().Value.RootElement)); + } + #pragma warning disable CA1812 // uninstantiated internal class private sealed class ParametersData { diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/OpenAIFunction.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/OpenAIFunction.cs index af082282e7e8..87435c7ea44d 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/OpenAIFunction.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/OpenAIFunction.cs @@ -313,7 +313,8 @@ private static void InsertNullTypeIfRequired(bool insertNullType, JsonObject jso { return; } - if (typeValue is JsonArray jsonArray && !jsonArray.Contains(NullType)) + if (typeValue is JsonArray jsonArray && + !jsonArray.Any(static x => x is JsonValue jv && jv.GetValueKind() == JsonValueKind.String && jv.GetValue() == NullType)) { jsonArray.Add(NullType); }