From b03972549cea8434f73ede5ab02ed9ed0240fbdf Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 19 Sep 2025 15:36:38 -0400 Subject: [PATCH] Fix a couple of issues in M.E.AI.OpenAI clients 1. Setting AllowMultipleToolCalls if there aren't any tools results in failure. Fix it to only set the property if tools have been added. 2. The chat completion service fails if a participant name containing anything other than a constrained set of characters are included. Fix it with a sanitized value. --- .../CHANGELOG.md | 3 ++ .../OpenAIChatClient.cs | 50 +++++++++++++++--- .../OpenAIResponsesChatClient.cs | 6 ++- .../OpenAIChatClientTests.cs | 51 +++++++++++++++---- .../OpenAIConversionTests.cs | 9 ++-- 5 files changed, 100 insertions(+), 19 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index a50aea8dea3..d531f3dd445 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -4,6 +4,9 @@ - Added M.E.AI to OpenAI conversions for response format types. - Added `ResponseTool` to `AITool` conversions. +- Fixed the handling of `HostedCodeInterpreterTool` with Responses when no file IDs were provided. +- Fixed an issue where requests would fail when AllowMultipleToolCalls was set with no tools provided. +- Fixed an issue where requests would fail when an AuthorName was provided containing invalid characters. ## 9.9.0-preview.1.25458.4 diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index fb0ee48a236..33f22dda420 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -10,6 +10,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -19,14 +20,16 @@ #pragma warning disable CA1308 // Normalize strings to uppercase #pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) #pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable S2333 // Unnecessary partial #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1202 // Elements should be ordered by access +#pragma warning disable SA1203 // Constants should appear before fields #pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; /// Represents an for an OpenAI or . -internal sealed class OpenAIChatClient : IChatClient +internal sealed partial class OpenAIChatClient : IChatClient { // These delegate instances are used to call the internal overloads of CompleteChatAsync and CompleteChatStreamingAsync that accept // a RequestOptions. These should be replaced once a better way to pass RequestOptions is available. @@ -157,10 +160,11 @@ internal static ChatTool ToOpenAIChatTool(AIFunctionDeclaration aiFunction, Chat input.Role == OpenAIClientExtensions.ChatRoleDeveloper) { var parts = ToOpenAIChatContent(input.Contents); + string? name = SanitizeAuthorName(input.AuthorName); yield return - input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = input.AuthorName } : - input.Role == OpenAIClientExtensions.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } : - new UserChatMessage(parts) { ParticipantName = input.AuthorName }; + input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = name } : + input.Role == OpenAIClientExtensions.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = name } : + new UserChatMessage(parts) { ParticipantName = name }; } else if (input.Role == ChatRole.Tool) { @@ -233,7 +237,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunctionDeclaration aiFunction, Chat new(ChatMessageContentPart.CreateTextPart(string.Empty)); } - message.ParticipantName = input.AuthorName; + message.ParticipantName = SanitizeAuthorName(input.AuthorName); message.Refusal = refusal; yield return message; @@ -568,7 +572,6 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.TopP ??= options.TopP; result.PresencePenalty ??= options.PresencePenalty; result.Temperature ??= options.Temperature; - result.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; result.Seed ??= options.Seed; if (options.StopSequences is { Count: > 0 } stopSequences) @@ -589,6 +592,11 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) } } + if (result.Tools.Count > 0) + { + result.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; + } + if (result.ToolChoice is null && result.Tools.Count > 0) { switch (options.ToolMode) @@ -749,6 +757,27 @@ internal static void ConvertContentParts(ChatMessageContent content, IList new ChatFinishReason(s), }; + /// Sanitizes the author name to be appropriate for including as an OpenAI participant name. + private static string? SanitizeAuthorName(string? name) + { + if (name is not null) + { + const int MaxLength = 64; + + name = InvalidAuthorNameRegex().Replace(name, string.Empty); + if (name.Length == 0) + { + name = null; + } + else if (name.Length > MaxLength) + { + name = name.Substring(0, MaxLength); + } + } + + return name; + } + /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. private sealed class FunctionCallInfo { @@ -756,4 +785,13 @@ private sealed class FunctionCallInfo public string? Name; public StringBuilder? Arguments; } + + private const string InvalidAuthorNamePattern = @"[^a-zA-Z0-9_]+"; +#if NET + [GeneratedRegex(InvalidAuthorNamePattern)] + private static partial Regex InvalidAuthorNameRegex(); +#else + private static Regex InvalidAuthorNameRegex() => _invalidAuthorNameRegex; + private static readonly Regex _invalidAuthorNameRegex = new(InvalidAuthorNamePattern, RegexOptions.Compiled); +#endif } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index d094f5f8581..aa9807d16e6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -418,7 +418,6 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt // Handle strongly-typed properties. result.MaxOutputTokenCount ??= options.MaxOutputTokens; - result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; result.PreviousResponseId ??= options.ConversationId; result.Temperature ??= options.Temperature; result.TopP ??= options.TopP; @@ -530,6 +529,11 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt } } + if (result.Tools.Count > 0) + { + result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; + } + if (result.ToolChoice is null && result.Tools.Count > 0) { switch (options.ToolMode) diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index bdf3b2e7c0a..eca94315fa0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -152,6 +152,7 @@ public async Task BasicRequestResponse_NonStreaming() var response = await client.GetResponseAsync("hello", new() { + AllowMultipleToolCalls = false, MaxOutputTokens = 10, Temperature = 0.5f, }); @@ -658,15 +659,46 @@ public async Task StronglyTypedOptions_AllSent() { const string Input = """ { - "messages":[{"role":"user","content":"hello"}], - "model":"gpt-4o-mini", - "logprobs":true, - "top_logprobs":42, - "logit_bias":{"12":34}, - "parallel_tool_calls":false, - "user":"12345", - "metadata":{"something":"else"}, - "store":true + "metadata": { + "something": "else" + }, + "user": "12345", + "messages": [ + { + "role": "user", + "content": "hello" + } + ], + "model": "gpt-4o-mini", + "top_logprobs": 42, + "store": true, + "logit_bias": { + "12": 34 + }, + "logprobs": true, + "tools": [ + { + "type": "function", + "function": { + "description": "", + "name": "GetPersonAge", + "parameters": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } + ], + "tool_choice": "auto", + "parallel_tool_calls": false } """; @@ -694,6 +726,7 @@ public async Task StronglyTypedOptions_AllSent() Assert.NotNull(await client.GetResponseAsync("hello", new() { AllowMultipleToolCalls = false, + Tools = [AIFunctionFactory.Create((string name) => 42, "GetPersonAge")], RawRepresentationFactory = (c) => { var openAIOptions = new ChatCompletionOptions diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 7724ad98360..ed00f856667 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -159,7 +159,7 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) List messages = [ new(ChatRole.System, "You are a helpful assistant."), - new(ChatRole.User, "Hello"), + new(ChatRole.User, "Hello") { AuthorName = "Jane" }, new(ChatRole.Assistant, [ new TextContent("Hi there!"), @@ -168,9 +168,9 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) ["param1"] = "value1", ["param2"] = 42 }), - ]), + ]) { AuthorName = "!@#$%John Smith^*)" }, new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), - new(ChatRole.Assistant, "The answer is 42."), + new(ChatRole.Assistant, "The answer is 42.") { AuthorName = "@#$#$@$" }, ]; ChatOptions? options = withOptions ? new ChatOptions { Instructions = "You talk like a parrot." } : null; @@ -196,6 +196,7 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) UserChatMessage m1 = Assert.IsType(convertedMessages[index + 1], exactMatch: false); Assert.Equal("Hello", Assert.Single(m1.Content).Text); + Assert.Equal("Jane", m1.ParticipantName); AssistantChatMessage m2 = Assert.IsType(convertedMessages[index + 2], exactMatch: false); Assert.Single(m2.Content); @@ -208,6 +209,7 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) ["param1"] = "value1", ["param2"] = 42 }), JsonSerializer.Deserialize(tc.FunctionArguments.ToMemory().Span))); + Assert.Equal("JohnSmith", m2.ParticipantName); ToolChatMessage m3 = Assert.IsType(convertedMessages[index + 3], exactMatch: false); Assert.Equal("callid123", m3.ToolCallId); @@ -215,6 +217,7 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) AssistantChatMessage m4 = Assert.IsType(convertedMessages[index + 4], exactMatch: false); Assert.Equal("The answer is 42.", Assert.Single(m4.Content).Text); + Assert.Null(m4.ParticipantName); } [Fact]