diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs index 95f0b409ed0b..e2538f782339 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs @@ -117,6 +117,60 @@ public async Task ExtraBodyOverridesFirstClassPropertyAsync() Assert.Equal(0.0, body.GetProperty("temperature").GetDouble()); } + [Theory] + [InlineData("tools")] + [InlineData("$.tools")] + public async Task ExtraBodyToolsDoesNotDuplicatePropertyAsync(string key) + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + [key] = new object[] + { + new { type = "web_search" }, + new { type = "function", function = new { name = "lookup" } }, + }, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + var tools = Assert.Single(body.EnumerateObject(), property => property.NameEquals("tools")); + Assert.Equal("web_search", tools.Value[0].GetProperty("type").GetString()); + Assert.Equal("function", tools.Value[1].GetProperty("type").GetString()); + Assert.Equal("lookup", tools.Value[1].GetProperty("function").GetProperty("name").GetString()); + } + + [Fact] + public async Task ExtraBodyNestedToolsPathDoesNotDuplicatePropertyAsync() + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["$.tools[0].type"] = "web_search", + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + var tools = Assert.Single(body.EnumerateObject(), property => property.NameEquals("tools")); + var tool = Assert.Single(tools.Value.EnumerateArray()); + Assert.Equal("web_search", tool.GetProperty("type").GetString()); + Assert.False(tool.TryGetProperty("function", out _)); + } + [Fact] public async Task ExtraBodyNestedDictionaryEmitsNestedJsonObjectAsync() { diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs index 402e33f95175..278b0c1f2872 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs @@ -650,6 +650,11 @@ protected override void PrepareChatOptionsForRequest(Microsoft.Extensions.AI.Cha /// internal static void ApplyExtraBodyEntry(ChatCompletionOptions options, string key, object? value) { + if (TryApplyExtraBodyTools(options, key, value)) + { + return; + } + string path = key.StartsWith("$.", StringComparison.Ordinal) ? key : $"$[{JsonSerializer.Serialize(key)}]"; @@ -669,6 +674,128 @@ internal static void ApplyExtraBodyEntry(ChatCompletionOptions options, string k #pragma warning restore SCME0001 } + private static bool TryApplyExtraBodyTools(ChatCompletionOptions options, string key, object? value) + { + if (key is "tools" or "$.tools") + { + if (value is null) + { + return false; + } + + JsonElement tools = JsonSerializer.SerializeToElement(value, value.GetType()); + if (tools.ValueKind != JsonValueKind.Array || tools.EnumerateArray().Any(tool => tool.ValueKind != JsonValueKind.Object)) + { + return false; + } + + options.Tools.Clear(); + foreach (JsonElement toolElement in tools.EnumerateArray()) + { + options.Tools.Add(CreatePatchedTool(toolElement)); + } + + return true; + } + + const string ToolsIndexPrefix = "$.tools["; + if (!key.StartsWith(ToolsIndexPrefix, StringComparison.Ordinal)) + { + return false; + } + + int closingBracket = key.IndexOf(']', ToolsIndexPrefix.Length); + if (closingBracket < 0 || + !int.TryParse(key[ToolsIndexPrefix.Length..closingBracket], out int toolIndex) || + toolIndex < 0) + { + return false; + } + + string toolPath = key[(closingBracket + 1)..]; + if (toolPath.Length > 0 && toolPath[0] is not ('.' or '[')) + { + return false; + } + + while (options.Tools.Count <= toolIndex) + { + options.Tools.Add(CreateEmptyPatchedTool()); + } + + if (toolPath.Length == 0) + { + if (value is null) + { + return false; + } + + JsonElement tool = JsonSerializer.SerializeToElement(value, value.GetType()); + if (tool.ValueKind != JsonValueKind.Object) + { + return false; + } + + options.Tools[toolIndex] = CreatePatchedTool(tool); + return true; + } + +#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only. + byte[] nestedPath = System.Text.Encoding.UTF8.GetBytes($"${toolPath}"); + if (value is null) + { + options.Tools[toolIndex].Patch.SetNull(nestedPath); + } + else + { + options.Tools[toolIndex].Patch.Set( + nestedPath, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(value, value.GetType()))); + } +#pragma warning restore SCME0001 + + return true; + } + + private static ChatTool CreatePatchedTool(JsonElement toolElement) + { + ChatTool tool = CreateEmptyPatchedTool(); + +#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only. + foreach (JsonProperty property in toolElement.EnumerateObject()) + { + string path = property.Name is "type" or "function" + ? $"$.{property.Name}" + : $"$[{JsonSerializer.Serialize(property.Name)}]"; + byte[] propertyPath = System.Text.Encoding.UTF8.GetBytes(path); + if (property.Value.ValueKind == JsonValueKind.Null) + { + tool.Patch.SetNull(propertyPath); + } + else + { + tool.Patch.Set(propertyPath, BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(property.Value))); + } + } +#pragma warning restore SCME0001 + + return tool; + } + + private static ChatTool CreateEmptyPatchedTool() + { + // ChatTool currently models function tools only. Use one as the SDK-owned carrier for the tools array, + // then remove its modeled defaults so ExtraBody remains the sole source of tool fields. + ChatTool tool = ChatTool.CreateFunctionTool("placeholder"); + +#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only. + tool.Patch.Remove(System.Text.Encoding.UTF8.GetBytes("$.type")); + tool.Patch.Remove(System.Text.Encoding.UTF8.GetBytes("$.function")); +#pragma warning restore SCME0001 + + return tool; + } + /// /// Applies all entries from . to . ///