Skip to content

Commit 7fd75c3

Browse files
roli-lpciclaudeSergeyMenshykhCopilot
authored
.Net: fix: prevent duplicate "null" in JSON Schema type arrays for nullable parameters (#13635)
## Summary - Fixes `InsertNullTypeIfRequired()` producing duplicate `"null"` entries in JSON Schema type arrays (e.g., `["string", "null", "null"]`) for `Nullable<T>` parameters with `= null` defaults - Replaces reference-equality guard (`JsonArray.Contains()`) with value-based `.Any()` check - Adds 3 regression tests covering both trigger paths and positive insertion ## Root Cause `InsertNullTypeIfRequired()` in `OpenAIFunction.cs` 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. For `Nullable<T>` parameters with `= null` defaults, `AIJsonUtilities.CreateJsonSchema()` correctly produces `["string", "null"]`. The strict-mode sanitizer then attempts to add `"null"` again — the broken guard lets it through, producing `["string", "null", "null"]`. Two trigger paths reach the same bug: 1. Optional parameters (`IsRequired = false`) → `insertNullType = true` 2. Schemas with `"nullable": true` keyword ## Changes | File | Change | |------|--------| | `dotnet/src/Connectors/Connectors.OpenAI/Core/OpenAIFunction.cs` | Replace `jsonArray.Contains(NullType)` with value-based `.Any()` check (follows existing pattern in `NormalizeAdditionalProperties`) | | `dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIFunctionTests.cs` | 3 new tests: duplicate prevention for optional params, duplicate prevention for `nullable` keyword, positive case (null inserted when absent) | ## Testing - 3 new unit tests added covering: - `ItDoesNotInsertDuplicateNullInTypeArrayForOptionalParameter` — schema with pre-existing `["string", "null"]` + `IsRequired = false` - `ItDoesNotInsertDuplicateNullInTypeArrayForNullableKeyword` — schema with `"nullable": true` + `IsRequired = true` - `ItInsertsNullInTypeArrayWhenAbsent` — schema with `["string"]` only → `"null"` correctly added Fixes #13527 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Co-authored-by: SergeyMenshykh <sergemenshikh@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f8c757b commit 7fd75c3

2 files changed

Lines changed: 68 additions & 1 deletion

File tree

dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/OpenAIFunctionTests.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,72 @@ public void ItCleansUpRestrictedSchemaKeywords(string typeName, string keyword,
299299
}
300300
}
301301

302+
[Fact]
303+
public void ItDoesNotInsertDuplicateNullInTypeArrayForOptionalParameter()
304+
{
305+
// Arrange — schema with type array already containing "null" (as AIJsonUtilities produces for Nullable<T>)
306+
var parameterSchema = KernelJsonSchema.Parse("""{"type":["string","null"],"description":"A nullable param"}""");
307+
OpenAIFunction f = KernelFunctionFactory.CreateFromMethod(
308+
() => { },
309+
parameters: [new KernelParameterMetadata("param1") { Description = "A nullable param", IsRequired = false, Schema = parameterSchema }]).Metadata.ToOpenAIFunction();
310+
311+
// Act
312+
ChatTool result = f.ToFunctionDefinition(allowStrictSchemaAdherence: true);
313+
ParametersData pd = JsonSerializer.Deserialize<ParametersData>(result.FunctionParameters.ToString())!;
314+
315+
// Assert
316+
Assert.NotNull(pd.properties);
317+
Assert.Single(pd.properties);
318+
var expectedSchema = """{"type":["string","null"],"description":"A nullable param"}""";
319+
Assert.Equal(
320+
JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)),
321+
JsonSerializer.Serialize(pd.properties.First().Value.RootElement));
322+
}
323+
324+
[Fact]
325+
public void ItDoesNotInsertDuplicateNullInTypeArrayForNullableKeyword()
326+
{
327+
// Arrange — schema with "nullable": true and type array already containing "null"
328+
var parameterSchema = KernelJsonSchema.Parse("""{"type":["string","null"],"nullable":true,"description":"A nullable param"}""");
329+
OpenAIFunction f = KernelFunctionFactory.CreateFromMethod(
330+
() => { },
331+
parameters: [new KernelParameterMetadata("param1") { Description = "A nullable param", IsRequired = true, Schema = parameterSchema }]).Metadata.ToOpenAIFunction();
332+
333+
// Act
334+
ChatTool result = f.ToFunctionDefinition(allowStrictSchemaAdherence: true);
335+
ParametersData pd = JsonSerializer.Deserialize<ParametersData>(result.FunctionParameters.ToString())!;
336+
337+
// Assert — "nullable" keyword is removed in strict mode, type array should not gain duplicate "null"
338+
Assert.NotNull(pd.properties);
339+
Assert.Single(pd.properties);
340+
var expectedSchema = """{"type":["string","null"],"description":"A nullable param"}""";
341+
Assert.Equal(
342+
JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)),
343+
JsonSerializer.Serialize(pd.properties.First().Value.RootElement));
344+
}
345+
346+
[Fact]
347+
public void ItInsertsNullInTypeArrayWhenAbsent()
348+
{
349+
// Arrange — schema with type array that does NOT contain "null"
350+
var parameterSchema = KernelJsonSchema.Parse("""{"type":["string"],"description":"An optional param"}""");
351+
OpenAIFunction f = KernelFunctionFactory.CreateFromMethod(
352+
() => { },
353+
parameters: [new KernelParameterMetadata("param1") { Description = "An optional param", IsRequired = false, Schema = parameterSchema }]).Metadata.ToOpenAIFunction();
354+
355+
// Act
356+
ChatTool result = f.ToFunctionDefinition(allowStrictSchemaAdherence: true);
357+
ParametersData pd = JsonSerializer.Deserialize<ParametersData>(result.FunctionParameters.ToString())!;
358+
359+
// Assert — "null" should be added to the type array
360+
Assert.NotNull(pd.properties);
361+
Assert.Single(pd.properties);
362+
var expectedSchema = """{"type":["string","null"],"description":"An optional param"}""";
363+
Assert.Equal(
364+
JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)),
365+
JsonSerializer.Serialize(pd.properties.First().Value.RootElement));
366+
}
367+
302368
#pragma warning disable CA1812 // uninstantiated internal class
303369
private sealed class ParametersData
304370
{

dotnet/src/Connectors/Connectors.OpenAI/Core/OpenAIFunction.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,8 @@ private static void InsertNullTypeIfRequired(bool insertNullType, JsonObject jso
313313
{
314314
return;
315315
}
316-
if (typeValue is JsonArray jsonArray && !jsonArray.Contains(NullType))
316+
if (typeValue is JsonArray jsonArray &&
317+
!jsonArray.Any(static x => x is JsonValue jv && jv.GetValueKind() == JsonValueKind.String && jv.GetValue<string>() == NullType))
317318
{
318319
jsonArray.Add(NullType);
319320
}

0 commit comments

Comments
 (0)