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]