From d8216b8f4f94399e1eb4fe88823c506de571a4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Tue, 29 Apr 2025 12:49:28 -0500 Subject: [PATCH 01/14] Look for OpenAI.ChatCompletionOptions in top-level additional properties and stop looking for individually specific additional properties --- .../OpenAIChatClient.cs | 232 +++++------------- .../OpenAIChatClientTests.cs | 51 +--- 2 files changed, 74 insertions(+), 209 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index c78b495393b..c174c4a486f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -275,22 +275,6 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha Role = streamedRole, }; - // Populate it with any additional metadata from the OpenAI object. - if (update.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) - { - (responseUpdate.AdditionalProperties ??= [])[nameof(update.ContentTokenLogProbabilities)] = contentTokenLogProbs; - } - - if (update.RefusalTokenLogProbabilities is { Count: > 0 } refusalTokenLogProbs) - { - (responseUpdate.AdditionalProperties ??= [])[nameof(update.RefusalTokenLogProbabilities)] = refusalTokenLogProbs; - } - - if (fingerprint is not null) - { - (responseUpdate.AdditionalProperties ??= [])[nameof(update.SystemFingerprint)] = fingerprint; - } - // Transfer over content update items. if (update.ContentUpdate is { Count: > 0 }) { @@ -366,19 +350,6 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha } } - // Refusals are about the model not following the schema for tool calls. As such, if we have any refusal, - // add it to this function calling item. - if (refusal is not null) - { - (responseUpdate.AdditionalProperties ??= [])[nameof(ChatMessageContentPart.Refusal)] = refusal.ToString(); - } - - // Propagate additional relevant metadata. - if (fingerprint is not null) - { - (responseUpdate.AdditionalProperties ??= [])[nameof(ChatCompletion.SystemFingerprint)] = fingerprint; - } - yield return responseUpdate; } } @@ -417,20 +388,7 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple "mp3" or _ => "audio/mpeg", }; - var dc = new DataContent(audio.AudioBytes.ToMemory(), mimeType) - { - AdditionalProperties = new() { [nameof(audio.ExpiresAt)] = audio.ExpiresAt }, - }; - - if (audio.Id is string id) - { - dc.AdditionalProperties[nameof(audio.Id)] = id; - } - - if (audio.Transcript is string transcript) - { - dc.AdditionalProperties[nameof(audio.Transcript)] = transcript; - } + var dc = new DataContent(audio.AudioBytes.ToMemory(), mimeType); returnMessage.Contents.Add(dc); } @@ -465,159 +423,91 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple response.Usage = FromOpenAIUsage(tokenUsage); } - if (openAICompletion.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) - { - (response.AdditionalProperties ??= [])[nameof(openAICompletion.ContentTokenLogProbabilities)] = contentTokenLogProbs; - } + return response; + } - if (openAICompletion.Refusal is string refusal) + /// Converts an extensions options instance to an OpenAI options instance. + private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) + { + if (options is null) { - (response.AdditionalProperties ??= [])[nameof(openAICompletion.Refusal)] = refusal; + return new ChatCompletionOptions(); } - if (openAICompletion.RefusalTokenLogProbabilities is { Count: > 0 } refusalTokenLogProbs) + ChatCompletionOptions result; + if (options.AdditionalProperties is { Count: > 0 } additionalProperties && + additionalProperties.TryGetValue(nameof(ChatCompletionOptions), out ChatCompletionOptions? optionsFromAdditionalProperties)) { - (response.AdditionalProperties ??= [])[nameof(openAICompletion.RefusalTokenLogProbabilities)] = refusalTokenLogProbs; + result = optionsFromAdditionalProperties; } - - if (openAICompletion.SystemFingerprint is string systemFingerprint) + else { - (response.AdditionalProperties ??= [])[nameof(openAICompletion.SystemFingerprint)] = systemFingerprint; + result = new ChatCompletionOptions(); } - return response; - } - - /// Converts an extensions options instance to an OpenAI options instance. - private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) - { - ChatCompletionOptions result = new(); - - if (options is not null) - { - result.FrequencyPenalty = options.FrequencyPenalty; - result.MaxOutputTokenCount = options.MaxOutputTokens; - result.TopP = options.TopP; - result.PresencePenalty = options.PresencePenalty; - result.Temperature = options.Temperature; - result.AllowParallelToolCalls = options.AllowMultipleToolCalls; + result.FrequencyPenalty = options.FrequencyPenalty; + result.MaxOutputTokenCount = options.MaxOutputTokens; + result.TopP = options.TopP; + result.PresencePenalty = options.PresencePenalty; + result.Temperature = options.Temperature; + result.AllowParallelToolCalls = options.AllowMultipleToolCalls; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - result.Seed = options.Seed; + result.Seed = options.Seed; #pragma warning restore OPENAI001 - if (options.StopSequences is { Count: > 0 } stopSequences) + if (options.StopSequences is { Count: > 0 } stopSequences) + { + foreach (string stopSequence in stopSequences) { - foreach (string stopSequence in stopSequences) - { - result.StopSequences.Add(stopSequence); - } + result.StopSequences.Add(stopSequence); } + } - if (options.AdditionalProperties is { Count: > 0 } additionalProperties) + if (options.Tools is { Count: > 0 } tools) + { + foreach (AITool tool in tools) { - if (additionalProperties.TryGetValue(nameof(result.AudioOptions), out ChatAudioOptions? audioOptions)) - { - result.AudioOptions = audioOptions; - } - - if (additionalProperties.TryGetValue(nameof(result.EndUserId), out string? endUserId)) - { - result.EndUserId = endUserId; - } - - if (additionalProperties.TryGetValue(nameof(result.IncludeLogProbabilities), out bool includeLogProbabilities)) - { - result.IncludeLogProbabilities = includeLogProbabilities; - } - - if (additionalProperties.TryGetValue(nameof(result.LogitBiases), out IDictionary? logitBiases)) + if (tool is AIFunction af) { - foreach (KeyValuePair kvp in logitBiases!) - { - result.LogitBiases[kvp.Key] = kvp.Value; - } - } - - if (additionalProperties.TryGetValue(nameof(result.Metadata), out IDictionary? metadata)) - { - foreach (KeyValuePair kvp in metadata) - { - result.Metadata[kvp.Key] = kvp.Value; - } - } - - if (additionalProperties.TryGetValue(nameof(result.OutputPrediction), out ChatOutputPrediction? outputPrediction)) - { - result.OutputPrediction = outputPrediction; - } - - if (additionalProperties.TryGetValue(nameof(result.ReasoningEffortLevel), out ChatReasoningEffortLevel reasoningEffortLevel)) - { - result.ReasoningEffortLevel = reasoningEffortLevel; - } - - if (additionalProperties.TryGetValue(nameof(result.ResponseModalities), out ChatResponseModalities responseModalities)) - { - result.ResponseModalities = responseModalities; - } - - if (additionalProperties.TryGetValue(nameof(result.StoredOutputEnabled), out bool storeOutputEnabled)) - { - result.StoredOutputEnabled = storeOutputEnabled; - } - - if (additionalProperties.TryGetValue(nameof(result.TopLogProbabilityCount), out int topLogProbabilityCountInt)) - { - result.TopLogProbabilityCount = topLogProbabilityCountInt; + result.Tools.Add(ToOpenAIChatTool(af)); } } - if (options.Tools is { Count: > 0 } tools) + if (result.Tools.Count > 0) { - foreach (AITool tool in tools) - { - if (tool is AIFunction af) - { - result.Tools.Add(ToOpenAIChatTool(af)); - } - } - - if (result.Tools.Count > 0) + switch (options.ToolMode) { - switch (options.ToolMode) - { - case NoneChatToolMode: - result.ToolChoice = ChatToolChoice.CreateNoneChoice(); - break; - - case AutoChatToolMode: - case null: - result.ToolChoice = ChatToolChoice.CreateAutoChoice(); - break; - - case RequiredChatToolMode required: - result.ToolChoice = required.RequiredFunctionName is null ? - ChatToolChoice.CreateRequiredChoice() : - ChatToolChoice.CreateFunctionChoice(required.RequiredFunctionName); - break; - } + case NoneChatToolMode: + result.ToolChoice = ChatToolChoice.CreateNoneChoice(); + break; + + case AutoChatToolMode: + case null: + result.ToolChoice = ChatToolChoice.CreateAutoChoice(); + break; + + case RequiredChatToolMode required: + result.ToolChoice = required.RequiredFunctionName is null ? + ChatToolChoice.CreateRequiredChoice() : + ChatToolChoice.CreateFunctionChoice(required.RequiredFunctionName); + break; } } + } - if (options.ResponseFormat is ChatResponseFormatText) - { - result.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); - } - else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) - { - result.ResponseFormat = jsonFormat.Schema is { } jsonSchema ? - OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes( - JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ChatClientJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription) : - OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); - } + if (options.ResponseFormat is ChatResponseFormatText) + { + result.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); + } + else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) + { + result.ResponseFormat = jsonFormat.Schema is { } jsonSchema ? + OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName ?? "json_schema", + BinaryData.FromBytes( + JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ChatClientJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription) : + OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); } return result; diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 4fdc36b5280..5bc68bf801a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -184,9 +184,6 @@ public async Task BasicRequestResponse_NonStreaming() { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, }, response.Usage.AdditionalCounts); - - Assert.NotNull(response.AdditionalProperties); - Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(ChatCompletion.SystemFingerprint)]); } [Fact] @@ -257,8 +254,6 @@ public async Task BasicRequestResponse_Streaming() Assert.Equal(createdAt, updates[i].CreatedAt); Assert.Equal("gpt-4o-mini-2024-07-18", updates[i].ModelId); Assert.Equal(ChatRole.Assistant, updates[i].Role); - Assert.NotNull(updates[i].AdditionalProperties); - Assert.Equal("fp_f85bea6784", updates[i].AdditionalProperties![nameof(ChatCompletion.SystemFingerprint)]); Assert.Equal(i == 10 ? 0 : 1, updates[i].Contents.Count); Assert.Equal(i < 10 ? null : ChatFinishReason.Stop, updates[i].FinishReason); } @@ -280,7 +275,7 @@ public async Task BasicRequestResponse_Streaming() } [Fact] - public async Task NonStronglyTypedOptions_AllSent() + public async Task StronglyTypedOptions_AllSent() { const string Input = """ { @@ -317,20 +312,23 @@ public async Task NonStronglyTypedOptions_AllSent() using HttpClient httpClient = new(handler); using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + var options = new ChatCompletionOptions + { + StoredOutputEnabled = true, + IncludeLogProbabilities = true, + TopLogProbabilityCount = 42, + AllowParallelToolCalls = false, + EndUserId = "12345", + }; + options.Metadata.Add("something", "else"); + options.LogitBiases.Add(12, 34); + Assert.NotNull(await client.GetResponseAsync("hello", new() { AllowMultipleToolCalls = false, AdditionalProperties = new() { - ["StoredOutputEnabled"] = true, - ["Metadata"] = new Dictionary - { - ["something"] = "else", - }, - ["LogitBiases"] = new Dictionary { { 12, 34 } }, - ["IncludeLogProbabilities"] = true, - ["TopLogProbabilityCount"] = 42, - ["EndUserId"] = "12345", + [nameof(ChatCompletionOptions)] = options }, })); } @@ -446,9 +444,6 @@ public async Task MultipleMessages_NonStreaming() { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, }, response.Usage.AdditionalCounts); - - Assert.NotNull(response.AdditionalProperties); - Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(ChatCompletion.SystemFingerprint)]); } [Fact] @@ -546,9 +541,6 @@ public async Task MultiPartSystemMessage_NonStreaming() { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, }, response.Usage.AdditionalCounts); - - Assert.NotNull(response.AdditionalProperties); - Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(ChatCompletion.SystemFingerprint)]); } [Fact] @@ -647,9 +639,6 @@ public async Task EmptyAssistantMessage_NonStreaming() { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, }, response.Usage.AdditionalCounts); - - Assert.NotNull(response.AdditionalProperties); - Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(ChatCompletion.SystemFingerprint)]); } [Fact] @@ -767,9 +756,6 @@ public async Task FunctionCallContent_NonStreaming() FunctionCallContent fcc = Assert.IsType(response.Messages.Single().Contents[0]); Assert.Equal("GetPersonAge", fcc.Name); AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); - - Assert.NotNull(response.AdditionalProperties); - Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(ChatCompletion.SystemFingerprint)]); } [Fact] @@ -852,9 +838,6 @@ public async Task UnavailableBuiltInFunctionCall_NonStreaming() Assert.Single(response.Messages.Single().Contents); TextContent fcc = Assert.IsType(response.Messages.Single().Contents[0]); - - Assert.NotNull(response.AdditionalProperties); - Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(ChatCompletion.SystemFingerprint)]); } [Fact] @@ -946,8 +929,6 @@ public async Task FunctionCallContent_Streaming() Assert.Equal(createdAt, updates[i].CreatedAt); Assert.Equal("gpt-4o-mini-2024-07-18", updates[i].ModelId); Assert.Equal(ChatRole.Assistant, updates[i].Role); - Assert.NotNull(updates[i].AdditionalProperties); - Assert.Equal("fp_f85bea6784", updates[i].AdditionalProperties![nameof(ChatCompletion.SystemFingerprint)]); Assert.Equal(i < 7 ? null : ChatFinishReason.ToolCalls, updates[i].FinishReason); } @@ -1111,9 +1092,6 @@ public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, }, response.Usage.AdditionalCounts); - - Assert.NotNull(response.AdditionalProperties); - Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(ChatCompletion.SystemFingerprint)]); } [Fact] @@ -1229,9 +1207,6 @@ private static async Task DataContentMessage_Image_AdditionalPropertyDetail_NonS { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, }, response.Usage.AdditionalCounts); - - Assert.NotNull(response.AdditionalProperties); - Assert.Equal("fp_b705f0c291", response.AdditionalProperties[nameof(ChatCompletion.SystemFingerprint)]); } private static IChatClient CreateChatClient(HttpClient httpClient, string modelId) => From 4faf97db87e0c2406a03d52bf16c650cb99b0971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Tue, 29 Apr 2025 16:24:33 -0500 Subject: [PATCH 02/14] Add RawRepresentation to ChatOptions and use it in OpenAI and AzureAIInference --- .../ChatCompletion/ChatOptions.cs | 5 + .../AzureAIInferenceChatClient.cs | 150 ++++++++---------- .../OpenAIChatClient.cs | 12 +- .../AzureAIInferenceChatClientTests.cs | 12 +- .../OpenAIChatClientTests.cs | 12 +- 5 files changed, 87 insertions(+), 104 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index 8b3bef838dd..daf5d5011c3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -118,6 +118,11 @@ public string? ChatThreadId [JsonIgnore] public IList? Tools { get; set; } + /// + /// Gets or sets the raw representation of the chat options from an underlying implementation. + /// + public object? RawRepresentation { get; set; } + /// Gets or sets any additional properties associated with the options. public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index ea66cc191e4..4da4e6f2cc1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -273,109 +273,99 @@ private static ChatRole ToChatRole(global::Azure.AI.Inference.ChatRole role) => finishReason == CompletionsFinishReason.ToolCalls ? ChatFinishReason.ToolCalls : new(s); + private ChatCompletionsOptions CreateAzureAIOptions(IEnumerable chatContents, ChatOptions? options) => + new(ToAzureAIInferenceChatMessages(chatContents)) + { + Model = options?.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.") + }; + /// Converts an extensions options instance to an AzureAI options instance. private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatContents, ChatOptions? options) { - ChatCompletionsOptions result = new(ToAzureAIInferenceChatMessages(chatContents)) + if (options is null) { - Model = options?.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.") - }; + return CreateAzureAIOptions(chatContents, options); + } - if (options is not null) + ChatCompletionsOptions result; + if (options.RawRepresentation is ChatCompletionsOptions azureAIOptions) { - result.FrequencyPenalty = options.FrequencyPenalty; - result.MaxTokens = options.MaxOutputTokens; - result.NucleusSamplingFactor = options.TopP; - result.PresencePenalty = options.PresencePenalty; - result.Temperature = options.Temperature; - result.Seed = options.Seed; - - if (options.StopSequences is { Count: > 0 } stopSequences) - { - foreach (string stopSequence in stopSequences) - { - result.StopSequences.Add(stopSequence); - } - } + result = azureAIOptions; + result.Messages = ToAzureAIInferenceChatMessages(chatContents).ToList(); + result.Model = options.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options."); + } + else + { + result = CreateAzureAIOptions(chatContents, options); + } + + result.FrequencyPenalty = options.FrequencyPenalty; + result.MaxTokens = options.MaxOutputTokens; + result.NucleusSamplingFactor = options.TopP; + result.PresencePenalty = options.PresencePenalty; + result.Temperature = options.Temperature; + result.Seed = options.Seed; - // These properties are strongly typed on ChatOptions but not on ChatCompletionsOptions. - if (options.TopK is int topK) + if (options.StopSequences is { Count: > 0 } stopSequences) + { + foreach (string stopSequence in stopSequences) { - result.AdditionalProperties["top_k"] = new BinaryData(JsonSerializer.SerializeToUtf8Bytes(topK, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(int)))); + result.StopSequences.Add(stopSequence); } + } - if (options.AdditionalProperties is { } props) + if (options.Tools is { Count: > 0 } tools) + { + foreach (AITool tool in tools) { - foreach (var prop in props) + if (tool is AIFunction af) { - switch (prop.Key) - { - // Propagate everything else to the ChatCompletionsOptions' AdditionalProperties. - default: - if (prop.Value is not null) - { - byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); - result.AdditionalProperties[prop.Key] = new BinaryData(data); - } - - break; - } + result.Tools.Add(ToAzureAIChatTool(af)); } } - if (options.Tools is { Count: > 0 } tools) + switch (options.ToolMode) { - foreach (AITool tool in tools) - { - if (tool is AIFunction af) - { - result.Tools.Add(ToAzureAIChatTool(af)); - } - } - - switch (options.ToolMode) - { - case NoneChatToolMode: - result.ToolChoice = ChatCompletionsToolChoice.None; - break; + case NoneChatToolMode: + result.ToolChoice = ChatCompletionsToolChoice.None; + break; - case AutoChatToolMode: - case null: - result.ToolChoice = ChatCompletionsToolChoice.Auto; - break; + case AutoChatToolMode: + case null: + result.ToolChoice = ChatCompletionsToolChoice.Auto; + break; - case RequiredChatToolMode required: - result.ToolChoice = required.RequiredFunctionName is null ? - ChatCompletionsToolChoice.Required : - new ChatCompletionsToolChoice(new FunctionDefinition(required.RequiredFunctionName)); - break; - } + case RequiredChatToolMode required: + result.ToolChoice = required.RequiredFunctionName is null ? + ChatCompletionsToolChoice.Required : + new ChatCompletionsToolChoice(new FunctionDefinition(required.RequiredFunctionName)); + break; } + } - if (options.ResponseFormat is ChatResponseFormatText) + if (options.ResponseFormat is ChatResponseFormatText) + { + result.ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat(); + } + else if (options.ResponseFormat is ChatResponseFormatJson json) + { + if (json.Schema is { } schema) { - result.ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat(); + var tool = JsonSerializer.Deserialize(schema, JsonContext.Default.AzureAIChatToolJson)!; + result.ResponseFormat = ChatCompletionsResponseFormat.CreateJsonFormat( + json.SchemaName ?? "json_schema", + new Dictionary + { + ["type"] = _objectString, + ["properties"] = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool.Properties, JsonContext.Default.DictionaryStringJsonElement)), + ["required"] = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool.Required, JsonContext.Default.ListString)), + ["additionalProperties"] = _falseString, + }, + json.SchemaDescription); } - else if (options.ResponseFormat is ChatResponseFormatJson json) + else { - if (json.Schema is { } schema) - { - var tool = JsonSerializer.Deserialize(schema, JsonContext.Default.AzureAIChatToolJson)!; - result.ResponseFormat = ChatCompletionsResponseFormat.CreateJsonFormat( - json.SchemaName ?? "json_schema", - new Dictionary - { - ["type"] = _objectString, - ["properties"] = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool.Properties, JsonContext.Default.DictionaryStringJsonElement)), - ["required"] = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool.Required, JsonContext.Default.ListString)), - ["additionalProperties"] = _falseString, - }, - json.SchemaDescription); - } - else - { - result.ResponseFormat = ChatCompletionsResponseFormat.CreateJsonFormat(); - } + result.ResponseFormat = ChatCompletionsResponseFormat.CreateJsonFormat(); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index c174c4a486f..fda320dccb5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -434,16 +434,8 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) return new ChatCompletionOptions(); } - ChatCompletionOptions result; - if (options.AdditionalProperties is { Count: > 0 } additionalProperties && - additionalProperties.TryGetValue(nameof(ChatCompletionOptions), out ChatCompletionOptions? optionsFromAdditionalProperties)) - { - result = optionsFromAdditionalProperties; - } - else - { - result = new ChatCompletionOptions(); - } + ChatCompletionOptions result = options.RawRepresentation is ChatCompletionOptions openAIOptions ? + openAIOptions : new ChatCompletionOptions(); result.FrequencyPenalty = options.FrequencyPenalty; result.MaxOutputTokenCount = options.MaxOutputTokens; diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 788b8568607..b3ca4db7a61 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -269,21 +269,21 @@ public async Task AdditionalOptions_NonStreaming() using HttpClient httpClient = new(handler); using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + ChatCompletionsOptions azureAIOptions = new(); + azureAIOptions.AdditionalProperties.Add("top_k", new BinaryData(JsonSerializer.SerializeToUtf8Bytes(40, typeof(object)))); + azureAIOptions.AdditionalProperties.Add("something_else", new BinaryData(JsonSerializer.SerializeToUtf8Bytes("value1", typeof(object)))); + azureAIOptions.AdditionalProperties.Add("and_something_further", new BinaryData(JsonSerializer.SerializeToUtf8Bytes(123, typeof(object)))); + Assert.NotNull(await client.GetResponseAsync("hello", new() { MaxOutputTokens = 10, Temperature = 0.5f, TopP = 0.5f, - TopK = 40, FrequencyPenalty = 0.75f, PresencePenalty = 0.5f, Seed = 42, StopSequences = ["yes", "no"], - AdditionalProperties = new() - { - ["something_else"] = "value1", - ["and_something_further"] = 123, - }, + RawRepresentation = azureAIOptions, })); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 5bc68bf801a..a10eeb85dd3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -312,24 +312,20 @@ public async Task StronglyTypedOptions_AllSent() using HttpClient httpClient = new(handler); using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); - var options = new ChatCompletionOptions + var openAIOptions = new ChatCompletionOptions { StoredOutputEnabled = true, IncludeLogProbabilities = true, TopLogProbabilityCount = 42, - AllowParallelToolCalls = false, EndUserId = "12345", }; - options.Metadata.Add("something", "else"); - options.LogitBiases.Add(12, 34); + openAIOptions.Metadata.Add("something", "else"); + openAIOptions.LogitBiases.Add(12, 34); Assert.NotNull(await client.GetResponseAsync("hello", new() { AllowMultipleToolCalls = false, - AdditionalProperties = new() - { - [nameof(ChatCompletionOptions)] = options - }, + RawRepresentation = openAIOptions })); } From 7774d27d65be23399583c07785c3707b714a0fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Tue, 29 Apr 2025 16:26:58 -0500 Subject: [PATCH 03/14] Remove now unused locals --- .../Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index fda320dccb5..c85d289d5a6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -246,11 +246,9 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha Dictionary? functionCallInfos = null; ChatRole? streamedRole = null; ChatFinishReason? finishReason = null; - StringBuilder? refusal = null; string? responseId = null; DateTimeOffset? createdAt = null; string? modelId = null; - string? fingerprint = null; // Process each update as it arrives await foreach (StreamingChatCompletionUpdate update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) @@ -261,7 +259,6 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha responseId ??= update.CompletionId; createdAt ??= update.CreatedAt; modelId ??= update.Model; - fingerprint ??= update.SystemFingerprint; // Create the response content object. ChatResponseUpdate responseUpdate = new() @@ -287,12 +284,6 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha } } - // Transfer over refusal updates. - if (update.RefusalUpdate is not null) - { - _ = (refusal ??= new()).Append(update.RefusalUpdate); - } - // Transfer over tool call updates. if (update.ToolCallUpdates is { Count: > 0 } toolCallUpdates) { From 9c21b76f1c7f8ad22eda300ac77e69bf18233a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Tue, 29 Apr 2025 16:52:43 -0500 Subject: [PATCH 04/14] Add [JsonIgnore] and update roundtrip tests --- .../ChatCompletion/ChatOptions.cs | 7 +++++++ .../ChatCompletion/ChatOptionsTests.cs | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index daf5d5011c3..82458a8f0da 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -121,6 +121,12 @@ public string? ChatThreadId /// /// Gets or sets the raw representation of the chat options from an underlying implementation. /// + /// + /// If a is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model if needed. + /// + [JsonIgnore] public object? RawRepresentation { get; set; } /// Gets or sets any additional properties associated with the options. @@ -149,6 +155,7 @@ public virtual ChatOptions Clone() ModelId = ModelId, AllowMultipleToolCalls = AllowMultipleToolCalls, ToolMode = ToolMode, + RawRepresentation = RawRepresentation, AdditionalProperties = AdditionalProperties?.Clone(), }; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs index 67bbfb6d3db..213e634164d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs @@ -28,6 +28,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(options.ToolMode); Assert.Null(options.Tools); Assert.Null(options.AdditionalProperties); + Assert.Null(options.RawRepresentation); ChatOptions clone = options.Clone(); Assert.Null(clone.ConversationId); @@ -45,6 +46,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(clone.ToolMode); Assert.Null(clone.Tools); Assert.Null(clone.AdditionalProperties); + Assert.Null(clone.RawRepresentation); } [Fact] @@ -69,6 +71,8 @@ public void Properties_Roundtrip() ["key"] = "value", }; + object rawRepresentation = new(); + options.ConversationId = "12345"; options.Temperature = 0.1f; options.MaxOutputTokens = 2; @@ -83,6 +87,7 @@ public void Properties_Roundtrip() options.AllowMultipleToolCalls = true; options.ToolMode = ChatToolMode.RequireAny; options.Tools = tools; + options.RawRepresentation = rawRepresentation; options.AdditionalProperties = additionalProps; Assert.Equal("12345", options.ConversationId); @@ -99,6 +104,7 @@ public void Properties_Roundtrip() Assert.True(options.AllowMultipleToolCalls); Assert.Same(ChatToolMode.RequireAny, options.ToolMode); Assert.Same(tools, options.Tools); + Assert.Same(rawRepresentation, options.RawRepresentation); Assert.Same(additionalProps, options.AdditionalProperties); ChatOptions clone = options.Clone(); @@ -116,6 +122,7 @@ public void Properties_Roundtrip() Assert.True(clone.AllowMultipleToolCalls); Assert.Same(ChatToolMode.RequireAny, clone.ToolMode); Assert.Equal(tools, clone.Tools); + Assert.Same(rawRepresentation, clone.RawRepresentation); Assert.Equal(additionalProps, clone.AdditionalProperties); } @@ -153,6 +160,7 @@ public void JsonSerialization_Roundtrips() AIFunctionFactory.Create(() => 42), AIFunctionFactory.Create(() => 43), ]; + options.RawRepresentation = new object(); options.AdditionalProperties = additionalProps; string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.ChatOptions); @@ -175,6 +183,7 @@ public void JsonSerialization_Roundtrips() Assert.False(deserialized.AllowMultipleToolCalls); Assert.Equal(ChatToolMode.RequireAny, deserialized.ToolMode); Assert.Null(deserialized.Tools); + Assert.Null(deserialized.RawRepresentation); Assert.NotNull(deserialized.AdditionalProperties); Assert.Single(deserialized.AdditionalProperties); From 0130559254cdf908a855e2c3415a17876a2933bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Wed, 30 Apr 2025 17:58:06 -0500 Subject: [PATCH 05/14] Overwirte properties only if the underlying model don't specify it already --- .../ChatCompletion/ChatOptions.cs | 14 +- .../AzureAIInferenceChatClient.cs | 88 +-- .../OpenAIChatClient.cs | 48 +- .../AzureAIInferenceChatClientTests.cs | 536 +++++++++++++++--- ...xtensions.AI.AzureAIInference.Tests.csproj | 1 + .../OpenAIChatClientTests.cs | 348 ++++++++++++ 6 files changed, 895 insertions(+), 140 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index 82458a8f0da..24bff75267a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -122,9 +122,17 @@ public string? ChatThreadId /// Gets or sets the raw representation of the chat options from an underlying implementation. /// /// - /// If a is created to represent some underlying object from another object - /// model, this property can be used to store that original object. This can be useful for debugging or - /// for enabling a consumer to access the underlying object model if needed. + /// The underlying implementation may have its own representation of options. + /// When or + /// is invoked with a , that implementation may convert the provided options into + /// its own representation in order to use it while performing the operation. For situations where a consumer knows + /// which concrete is being used and how it represents options, an instance of that + /// implementation-specific options type may be stored into this property, for + /// the implementation to use instead of creating a new instance. Such implementations + /// may mutate the supplied options instance further based on other settings supplied on this + /// instance or from other inputs, like the enumerable of s. This is typically used + /// in order to set an implementation-specific setting that isn't otherwise exposed from the strongly-typed properties + /// on . /// [JsonIgnore] public object? RawRepresentation { get; set; } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 4da4e6f2cc1..4a75ab3a3d9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -287,24 +287,22 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon return CreateAzureAIOptions(chatContents, options); } - ChatCompletionsOptions result; - if (options.RawRepresentation is ChatCompletionsOptions azureAIOptions) + if (options.RawRepresentation is ChatCompletionsOptions result) { - result = azureAIOptions; result.Messages = ToAzureAIInferenceChatMessages(chatContents).ToList(); - result.Model = options.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options."); + result.Model ??= options.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options."); } else { result = CreateAzureAIOptions(chatContents, options); } - result.FrequencyPenalty = options.FrequencyPenalty; - result.MaxTokens = options.MaxOutputTokens; - result.NucleusSamplingFactor = options.TopP; - result.PresencePenalty = options.PresencePenalty; - result.Temperature = options.Temperature; - result.Seed = options.Seed; + result.FrequencyPenalty ??= options.FrequencyPenalty; + result.MaxTokens ??= options.MaxOutputTokens; + result.NucleusSamplingFactor ??= options.TopP; + result.PresencePenalty ??= options.PresencePenalty; + result.Temperature ??= options.Temperature; + result.Seed ??= options.Seed; if (options.StopSequences is { Count: > 0 } stopSequences) { @@ -324,48 +322,54 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon } } - switch (options.ToolMode) + if (result.ToolChoice is null && result.Tools.Count > 0) { - case NoneChatToolMode: - result.ToolChoice = ChatCompletionsToolChoice.None; - break; + switch (options.ToolMode) + { + case NoneChatToolMode: + result.ToolChoice = ChatCompletionsToolChoice.None; + break; - case AutoChatToolMode: - case null: - result.ToolChoice = ChatCompletionsToolChoice.Auto; - break; + case AutoChatToolMode: + case null: + result.ToolChoice = ChatCompletionsToolChoice.Auto; + break; - case RequiredChatToolMode required: - result.ToolChoice = required.RequiredFunctionName is null ? - ChatCompletionsToolChoice.Required : - new ChatCompletionsToolChoice(new FunctionDefinition(required.RequiredFunctionName)); - break; + case RequiredChatToolMode required: + result.ToolChoice = required.RequiredFunctionName is null ? + ChatCompletionsToolChoice.Required : + new ChatCompletionsToolChoice(new FunctionDefinition(required.RequiredFunctionName)); + break; + } } } - if (options.ResponseFormat is ChatResponseFormatText) + if (result.ResponseFormat is null) { - result.ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat(); - } - else if (options.ResponseFormat is ChatResponseFormatJson json) - { - if (json.Schema is { } schema) + if (options.ResponseFormat is ChatResponseFormatText) { - var tool = JsonSerializer.Deserialize(schema, JsonContext.Default.AzureAIChatToolJson)!; - result.ResponseFormat = ChatCompletionsResponseFormat.CreateJsonFormat( - json.SchemaName ?? "json_schema", - new Dictionary - { - ["type"] = _objectString, - ["properties"] = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool.Properties, JsonContext.Default.DictionaryStringJsonElement)), - ["required"] = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool.Required, JsonContext.Default.ListString)), - ["additionalProperties"] = _falseString, - }, - json.SchemaDescription); + result.ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat(); } - else + else if (options.ResponseFormat is ChatResponseFormatJson json) { - result.ResponseFormat = ChatCompletionsResponseFormat.CreateJsonFormat(); + if (json.Schema is { } schema) + { + var tool = JsonSerializer.Deserialize(schema, JsonContext.Default.AzureAIChatToolJson)!; + result.ResponseFormat = ChatCompletionsResponseFormat.CreateJsonFormat( + json.SchemaName ?? "json_schema", + new Dictionary + { + ["type"] = _objectString, + ["properties"] = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool.Properties, JsonContext.Default.DictionaryStringJsonElement)), + ["required"] = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool.Required, JsonContext.Default.ListString)), + ["additionalProperties"] = _falseString, + }, + json.SchemaDescription); + } + else + { + result.ResponseFormat = ChatCompletionsResponseFormat.CreateJsonFormat(); + } } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index c85d289d5a6..58001806d7f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -425,17 +425,16 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) return new ChatCompletionOptions(); } - ChatCompletionOptions result = options.RawRepresentation is ChatCompletionOptions openAIOptions ? - openAIOptions : new ChatCompletionOptions(); - - result.FrequencyPenalty = options.FrequencyPenalty; - result.MaxOutputTokenCount = options.MaxOutputTokens; - result.TopP = options.TopP; - result.PresencePenalty = options.PresencePenalty; - result.Temperature = options.Temperature; - result.AllowParallelToolCalls = options.AllowMultipleToolCalls; + ChatCompletionOptions result = options.RawRepresentation as ChatCompletionOptions ?? new(); + + result.FrequencyPenalty ??= options.FrequencyPenalty; + result.MaxOutputTokenCount ??= options.MaxOutputTokens; + result.TopP ??= options.TopP; + result.PresencePenalty ??= options.PresencePenalty; + result.Temperature ??= options.Temperature; + result.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - result.Seed = options.Seed; + result.Seed ??= options.Seed; #pragma warning restore OPENAI001 if (options.StopSequences is { Count: > 0 } stopSequences) @@ -456,7 +455,7 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) } } - if (result.Tools.Count > 0) + if (result.ToolChoice is null && result.Tools.Count > 0) { switch (options.ToolMode) { @@ -478,19 +477,22 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) } } - if (options.ResponseFormat is ChatResponseFormatText) + if (result.ResponseFormat is null) { - result.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); - } - else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) - { - result.ResponseFormat = jsonFormat.Schema is { } jsonSchema ? - OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes( - JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ChatClientJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription) : - OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); + if (options.ResponseFormat is ChatResponseFormatText) + { + result.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); + } + else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) + { + result.ResponseFormat = jsonFormat.Schema is { } jsonSchema ? + OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName ?? "json_schema", + BinaryData.FromBytes( + JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ChatClientJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription) : + OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); + } } return result; diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index b3ca4db7a61..ab72b224e8a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Net.Http; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using Azure; using Azure.AI.Inference; @@ -32,6 +33,19 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("defaultModelId", () => client.AsIChatClient(" ")); } + [Fact] + public async Task NullModel_Throws() + { + ChatCompletionsClient client = new(new("http://localhost/some/endpoint"), new AzureKeyCredential("key")); + IChatClient chatClient = client.AsIChatClient(modelId: null); + + await Assert.ThrowsAsync(() => chatClient.GetResponseAsync("hello")); + await Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("hello").GetAsyncEnumerator().MoveNextAsync().AsTask()); + + await Assert.ThrowsAsync(() => chatClient.GetResponseAsync("hello", new ChatOptions { ModelId = null })); + await Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("hello", new ChatOptions { ModelId = null }).GetAsyncEnumerator().MoveNextAsync().AsTask()); + } + [Fact] public void AsIChatClient_ProducesExpectedMetadata() { @@ -76,54 +90,54 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.Null(pipeline.GetService("key")); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task BasicRequestResponse_NonStreaming(bool multiContent) - { - const string Input = """ - { - "messages": [{"role":"user", "content":"hello"}], - "max_tokens":10, - "temperature":0.5, - "model":"gpt-4o-mini" - } - """; + private const string BasicInputNonStreaming = """ + { + "messages": [{"role":"user", "content":"hello"}], + "max_tokens":10, + "temperature":0.5, + "model":"gpt-4o-mini" + } + """; - const string Output = """ + private const string BasicOutputNonStreaming = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "created": 1727888631, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ { - "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", - "object": "chat.completion", - "created": 1727888631, - "model": "gpt-4o-mini-2024-07-18", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello! How can I assist you today?", - "refusal": null - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 8, - "completion_tokens": 9, - "total_tokens": 17, - "prompt_tokens_details": { - "cached_tokens": 0 + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?", + "refusal": null }, - "completion_tokens_details": { - "reasoning_tokens": 0 - } - }, - "system_fingerprint": "fp_f85bea6784" + "logprobs": null, + "finish_reason": "stop" } - """; + ], + "usage": { + "prompt_tokens": 8, + "completion_tokens": 9, + "total_tokens": 17, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; - using VerbatimHttpHandler handler = new(Input, Output); + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BasicRequestResponse_NonStreaming(bool multiContent) + { + using VerbatimHttpHandler handler = new(BasicInputNonStreaming, BasicOutputNonStreaming); using HttpClient httpClient = new(handler); using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); @@ -153,50 +167,50 @@ [new ChatMessage(ChatRole.User, "hello".Select(c => (AIContent)new TextContent(c Assert.Equal(17, response.Usage.TotalTokenCount); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task BasicRequestResponse_Streaming(bool multiContent) - { - const string Input = """ - { - "messages": [{"role":"user", "content":"hello"}], - "max_tokens":20, - "temperature":0.5, - "stream":true, - "model":"gpt-4o-mini"} - """; + private const string BasicInputStreaming = """ + { + "messages": [{"role":"user", "content":"hello"}], + "max_tokens":20, + "temperature":0.5, + "stream":true, + "model":"gpt-4o-mini"} + """; - const string Output = """ - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + private const string BasicOutputStreaming = """ + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}],"usage":null} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" How"},"logprobs":null,"finish_reason":null}],"usage":null} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" How"},"logprobs":null,"finish_reason":null}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" can"},"logprobs":null,"finish_reason":null}],"usage":null} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" can"},"logprobs":null,"finish_reason":null}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":null} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" assist"},"logprobs":null,"finish_reason":null}],"usage":null} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" assist"},"logprobs":null,"finish_reason":null}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" today"},"logprobs":null,"finish_reason":null}],"usage":null} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" today"},"logprobs":null,"finish_reason":null}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":8,"completion_tokens":9,"total_tokens":17,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":8,"completion_tokens":9,"total_tokens":17,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}} - data: [DONE] + data: [DONE] - """; + """; - using VerbatimHttpHandler handler = new(Input, Output); + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BasicRequestResponse_Streaming(bool multiContent) + { + using VerbatimHttpHandler handler = new(BasicInputStreaming, BasicOutputStreaming); using HttpClient httpClient = new(handler); using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); @@ -230,6 +244,384 @@ [new ChatMessage(ChatRole.User, "hello".Select(c => (AIContent)new TextContent(c } } + [Fact] + public async Task IChatClient_WithNullModel_ChatOptions_WithNotNullModel_NonStreaming() + { + using VerbatimHttpHandler handler = new(BasicInputNonStreaming, BasicOutputNonStreaming); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + + var response = await client.GetResponseAsync("hello", new ChatOptions + { + ModelId = "gpt-4o-mini", + MaxOutputTokens = 10, + Temperature = 0.5f, + }); + Assert.NotNull(response); + Assert.Equal("Hello! How can I assist you today?", response.Text); + } + + [Fact] + public async Task IChatClient_WithNullModel_ChatOptions_WithNotNullModel_Streaming() + { + using VerbatimHttpHandler handler = new(BasicInputStreaming, BasicOutputStreaming); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + + string responseText = string.Empty; + await foreach (var update in client.GetStreamingResponseAsync("hello", new ChatOptions + { + ModelId = "gpt-4o-mini", + MaxOutputTokens = 20, + Temperature = 0.5f, + })) + { + responseText += update.Text; + } + + Assert.Equal("Hello! How can I assist you today?", responseText); + } + + [Fact] + public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.75, + "max_tokens":10, + "top_p":0.5, + "presence_penalty":0.5, + "temperature":0.5, + "seed":42, + "stop":["hello","world"], + "response_format":{"type":"text"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}}, + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type": "object","required": ["personName"],"properties": {"personName": {"description": "The person whose age is being requested","type": "string"}}}}} + ], + "tool_choice":"auto" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatCompletionsOptions azureAIOptions = new() + { + Messages = [new ChatRequestUserMessage("overwrite me!")], // this one should be overwritten. + Model = "gpt-4o-mini", + FrequencyPenalty = 0.75f, + MaxTokens = 10, + NucleusSamplingFactor = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, + Seed = 42, + }; + azureAIOptions.StopSequences.Add("hello"); // this one merges with the other. + azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); // this one merges with the other. + azureAIOptions.ToolChoice = ChatCompletionsToolChoice.Auto; + azureAIOptions.ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat(); + + ChatOptions chatOptions = new ChatOptions + { + RawRepresentation = azureAIOptions, + ModelId = null, + FrequencyPenalty = 0.1f, + MaxOutputTokens = 1, + TopP = 0.1f, + PresencePenalty = 0.1f, + Temperature = 0.1f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + var response = await client.GetResponseAsync("hello", chatOptions); + Assert.NotNull(response); + Assert.Equal("Hello! How can I assist you today?", response.Text); + } + + [Fact] + public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_Streaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.75, + "max_tokens":10, + "top_p":0.5, + "presence_penalty":0.5, + "temperature":0.5, + "seed":42, + "stop":["hello","world"], + "response_format":{"type":"text"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}}, + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type": "object","required": ["personName"],"properties": {"personName": {"description": "The person whose age is being requested","type": "string"}}}}} + ], + "tool_choice":"auto", + "stream":true + } + """; + + const string Output = """ + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Hello! "}}]} + + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"content":"How can I assist you today?"}}]} + + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]} + + data: [DONE] + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatCompletionsOptions azureAIOptions = new() + { + Messages = [new ChatRequestUserMessage("overwrite me!")], // this one should be overwritten. + Model = "gpt-4o-mini", + FrequencyPenalty = 0.75f, + MaxTokens = 10, + NucleusSamplingFactor = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, + Seed = 42, + }; + azureAIOptions.StopSequences.Add("hello"); // this one merges with the other. + azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); // this one merges with the other. + azureAIOptions.ToolChoice = ChatCompletionsToolChoice.Auto; + azureAIOptions.ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat(); + + ChatOptions chatOptions = new ChatOptions + { + RawRepresentation = azureAIOptions, + ModelId = null, + FrequencyPenalty = 0.1f, + MaxOutputTokens = 1, + TopP = 0.1f, + PresencePenalty = 0.1f, + Temperature = 0.1f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + string responseText = string.Empty; + await foreach (var update in client.GetStreamingResponseAsync("hello", chatOptions)) + { + responseText += update.Text; + } + + Assert.Equal("Hello! How can I assist you today?", responseText); + } + + [Fact] + public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.1, + "max_tokens":1, + "top_p":0.1, + "presence_penalty":0.1, + "temperature":0.1, + "seed":1, + "stop":["world"], + "response_format":{"type":"json_object"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} + ], + "tool_choice":"none" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatCompletionsOptions azureAIOptions = new(); + Assert.Empty(azureAIOptions.Messages); + Assert.Null(azureAIOptions.Model); + Assert.Null(azureAIOptions.FrequencyPenalty); + Assert.Null(azureAIOptions.MaxTokens); + Assert.Null(azureAIOptions.NucleusSamplingFactor); + Assert.Null(azureAIOptions.PresencePenalty); + Assert.Null(azureAIOptions.Temperature); + Assert.Null(azureAIOptions.Seed); + Assert.Empty(azureAIOptions.StopSequences); + Assert.Empty(azureAIOptions.Tools); + Assert.Null(azureAIOptions.ToolChoice); + Assert.Null(azureAIOptions.ResponseFormat); + + ChatOptions chatOptions = new ChatOptions + { + RawRepresentation = azureAIOptions, + ModelId = "gpt-4o-mini", + FrequencyPenalty = 0.1f, + MaxOutputTokens = 1, + TopP = 0.1f, + PresencePenalty = 0.1f, + Temperature = 0.1f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + var response = await client.GetResponseAsync("hello", chatOptions); + Assert.NotNull(response); + Assert.Equal("Hello! How can I assist you today?", response.Text); + } + + [Fact] + public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Streaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.1, + "max_tokens":1, + "top_p":0.1, + "presence_penalty":0.1, + "temperature":0.1, + "seed":1, + "stop":["world"], + "response_format":{"type":"json_object"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} + ], + "tool_choice":"none", + "stream":true + } + """; + + const string Output = """ + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Hello! "}}]} + + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"content":"How can I assist you today?"}}]} + + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]} + + data: [DONE] + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatCompletionsOptions azureAIOptions = new(); + Assert.Empty(azureAIOptions.Messages); + Assert.Null(azureAIOptions.Model); + Assert.Null(azureAIOptions.FrequencyPenalty); + Assert.Null(azureAIOptions.MaxTokens); + Assert.Null(azureAIOptions.NucleusSamplingFactor); + Assert.Null(azureAIOptions.PresencePenalty); + Assert.Null(azureAIOptions.Temperature); + Assert.Null(azureAIOptions.Seed); + Assert.Empty(azureAIOptions.StopSequences); + Assert.Empty(azureAIOptions.Tools); + Assert.Null(azureAIOptions.ToolChoice); + Assert.Null(azureAIOptions.ResponseFormat); + + ChatOptions chatOptions = new ChatOptions + { + RawRepresentation = azureAIOptions, + ModelId = "gpt-4o-mini", + FrequencyPenalty = 0.1f, + MaxOutputTokens = 1, + TopP = 0.1f, + PresencePenalty = 0.1f, + Temperature = 0.1f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + string responseText = string.Empty; + await foreach (var update in client.GetStreamingResponseAsync("hello", chatOptions)) + { + responseText += update.Text; + } + + Assert.Equal("Hello! How can I assist you today?", responseText); + } + + /// Converts an Extensions function to an AzureAI chat tool. + private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunction) + { + // Map to an intermediate model so that redundant properties are skipped. + var tool = JsonSerializer.Deserialize(aiFunction.JsonSchema)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool)); + return new(new FunctionDefinition(aiFunction.Name) + { + Description = aiFunction.Description, + Parameters = functionParameters, + }); + } + + /// Used to create the JSON payload for an AzureAI chat tool description. + private sealed class AzureAIChatToolJson + { + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public List Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + } + [Fact] public async Task AdditionalOptions_NonStreaming() { diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj index d992413109b..42891c72167 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj @@ -6,6 +6,7 @@ true + $(NoWarn);S104 diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index a10eeb85dd3..7d978f4db47 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -8,6 +8,8 @@ using System.ComponentModel; using System.Linq; using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; @@ -274,6 +276,352 @@ public async Task BasicRequestResponse_Streaming() }, usage.Details.AdditionalCounts); } + [Fact] + public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.75, + "max_completion_tokens":10, + "top_p":0.5, + "presence_penalty":0.5, + "temperature":0.5, + "seed":42, + "stop":["hello","world"], + "response_format":{"type":"text"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"additionalProperties":false,"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}}, + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"additionalProperties":false,"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} + ], + "tool_choice":"auto" + } + """; + + const string Output = """ + { + "id": "chatcmpl-123", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: "gpt-4o-mini"); + AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatCompletionOptions openAIOptions = new() + { + FrequencyPenalty = 0.75f, + MaxOutputTokenCount = 10, + TopP = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + Seed = 42, +#pragma warning restore OPENAI001 + }; + openAIOptions.StopSequences.Add("hello"); + openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + openAIOptions.ToolChoice = ChatToolChoice.CreateAutoChoice(); + openAIOptions.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); + + ChatOptions chatOptions = new() + { + RawRepresentation = openAIOptions, + ModelId = null, + FrequencyPenalty = 0.1f, + MaxOutputTokens = 1, + TopP = 0.1f, + PresencePenalty = 0.1f, + Temperature = 0.1f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + var response = await client.GetResponseAsync("hello", chatOptions); + Assert.NotNull(response); + Assert.Equal("Hello! How can I assist you today?", response.Text); + } + + [Fact] + public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_Streaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.75, + "max_completion_tokens":10, + "top_p":0.5, + "presence_penalty":0.5, + "temperature":0.5, + "seed":42, + "stop":["hello","world"], + "response_format":{"type":"text"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}},"additionalProperties":false}}}, + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}},"additionalProperties":false}}} + ], + "tool_choice":"auto", + "stream":true, + "stream_options":{"include_usage":true} + } + """; + + const string Output = """ + data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Hello! "}}]} + + data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{"content":"How can I assist you today?"}}]} + + data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]} + + data: [DONE] + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: "gpt-4o-mini"); + AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatCompletionOptions openAIOptions = new() + { + FrequencyPenalty = 0.75f, + MaxOutputTokenCount = 10, + TopP = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + Seed = 42, +#pragma warning restore OPENAI001 + }; + openAIOptions.StopSequences.Add("hello"); + openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + openAIOptions.ToolChoice = ChatToolChoice.CreateAutoChoice(); + openAIOptions.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); + + ChatOptions chatOptions = new() + { + RawRepresentation = openAIOptions, + ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. + FrequencyPenalty = 0.1f, + MaxOutputTokens = 1, + TopP = 0.1f, + PresencePenalty = 0.1f, + Temperature = 0.1f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + string responseText = string.Empty; + await foreach (var update in client.GetStreamingResponseAsync("hello", chatOptions)) + { + responseText += update.Text; + } + + Assert.Equal("Hello! How can I assist you today?", responseText); + } + + [Fact] + public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.1, + "max_completion_tokens":1, + "top_p":0.1, + "presence_penalty":0.1, + "temperature":0.1, + "seed":1, + "stop":["world"], + "response_format":{"type":"json_object"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"additionalProperties":false,"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} + ], + "tool_choice":"none" + } + """; + + const string Output = """ + { + "id": "chatcmpl-123", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: "gpt-4o-mini"); + AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatCompletionOptions openAIOptions = new(); + Assert.Null(openAIOptions.FrequencyPenalty); + Assert.Null(openAIOptions.MaxOutputTokenCount); + Assert.Null(openAIOptions.TopP); + Assert.Null(openAIOptions.PresencePenalty); + Assert.Null(openAIOptions.Temperature); +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + Assert.Null(openAIOptions.Seed); +#pragma warning restore OPENAI001 + Assert.Empty(openAIOptions.StopSequences); + Assert.Empty(openAIOptions.Tools); + Assert.Null(openAIOptions.ToolChoice); + Assert.Null(openAIOptions.ResponseFormat); + + ChatOptions chatOptions = new() + { + RawRepresentation = openAIOptions, + ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. + FrequencyPenalty = 0.1f, + MaxOutputTokens = 1, + TopP = 0.1f, + PresencePenalty = 0.1f, + Temperature = 0.1f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + var response = await client.GetResponseAsync("hello", chatOptions); + Assert.NotNull(response); + Assert.Equal("Hello! How can I assist you today?", response.Text); + } + + [Fact] + public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Streaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.1, + "max_completion_tokens":1, + "top_p":0.1, + "presence_penalty":0.1, + "temperature":0.1, + "seed":1, + "stop":["world"], + "response_format":{"type":"json_object"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"additionalProperties":false,"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} + ], + "tool_choice":"none", + "stream":true, + "stream_options":{"include_usage":true} + } + """; + + const string Output = """ + data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Hello! "}}]} + + data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{"content":"How can I assist you today?"}}]} + + data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]} + + data: [DONE] + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: "gpt-4o-mini"); + AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatCompletionOptions openAIOptions = new(); + Assert.Null(openAIOptions.FrequencyPenalty); + Assert.Null(openAIOptions.MaxOutputTokenCount); + Assert.Null(openAIOptions.TopP); + Assert.Null(openAIOptions.PresencePenalty); + Assert.Null(openAIOptions.Temperature); +#pragma warning disable OPENAI001 + Assert.Null(openAIOptions.Seed); +#pragma warning restore OPENAI001 + Assert.Empty(openAIOptions.StopSequences); + Assert.Empty(openAIOptions.Tools); + Assert.Null(openAIOptions.ToolChoice); + Assert.Null(openAIOptions.ResponseFormat); + + ChatOptions chatOptions = new() + { + RawRepresentation = openAIOptions, + ModelId = null, + FrequencyPenalty = 0.1f, + MaxOutputTokens = 1, + TopP = 0.1f, + PresencePenalty = 0.1f, + Temperature = 0.1f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + string responseText = string.Empty; + await foreach (var update in client.GetStreamingResponseAsync("hello", chatOptions)) + { + responseText += update.Text; + } + + Assert.Equal("Hello! How can I assist you today?", responseText); + } + + /// Converts an Extensions function to an OpenAI chat tool. + private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) + { + bool? strict = + aiFunction.AdditionalProperties.TryGetValue("strictJsonSchema", out object? strictObj) && + strictObj is bool strictValue ? + strictValue : null; + + // Map to an intermediate model so that redundant properties are skipped. + var tool = JsonSerializer.Deserialize(aiFunction.JsonSchema)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool)); + return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); + } + + /// Used to create the JSON payload for an OpenAI chat tool description. + private sealed class ChatToolJson + { + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public HashSet Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + + [JsonPropertyName("additionalProperties")] + public bool AdditionalProperties { get; set; } + } + [Fact] public async Task StronglyTypedOptions_AllSent() { From f4dc89a5eab1162f186954b885949c37c150a581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Thu, 1 May 2025 13:26:11 -0500 Subject: [PATCH 06/14] Clone RawRepresentation --- .../AzureAIInferenceChatClient.cs | 16 ++ .../OpenAIChatClient.cs | 23 ++- .../AzureAIInferenceChatClientTests.cs | 150 +++++++++++++++++ .../OpenAIChatClientTests.cs | 151 ++++++++++++++++++ 4 files changed, 339 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 4a75ab3a3d9..bc7d43b5e32 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -279,6 +281,17 @@ private ChatCompletionsOptions CreateAzureAIOptions(IEnumerable cha Model = options?.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.") }; + private static ChatCompletionsOptions CloneRawRepresentation(IJsonModel optionsAsModel) + { + using MemoryStream ms = new MemoryStream(); + using Utf8JsonWriter writer = new Utf8JsonWriter(ms); + optionsAsModel.Write(writer, ModelReaderWriterOptions.Json); + writer.Flush(); + + var reader = new Utf8JsonReader(ms.ToArray()); + return optionsAsModel.Create(ref reader, ModelReaderWriterOptions.Json); + } + /// Converts an extensions options instance to an AzureAI options instance. private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatContents, ChatOptions? options) { @@ -289,6 +302,9 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon if (options.RawRepresentation is ChatCompletionsOptions result) { + // Clone the options to avoid modifying the original. + ////TODO: ToolChoice is not being cloned. + result = CloneRawRepresentation(result); result.Messages = ToAzureAIInferenceChatMessages(chatContents).ToList(); result.Model ??= options.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options."); } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 58001806d7f..ebdd53000e5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ClientModel.Primitives; using System.Collections.Generic; +using System.IO; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -417,6 +419,17 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple return response; } + private static ChatCompletionOptions CloneRawRepresentation(IJsonModel optionsAsModel) + { + using MemoryStream ms = new MemoryStream(); + using Utf8JsonWriter writer = new Utf8JsonWriter(ms); + optionsAsModel.Write(writer, ModelReaderWriterOptions.Json); + writer.Flush(); + + var reader = new Utf8JsonReader(ms.ToArray()); + return optionsAsModel.Create(ref reader, ModelReaderWriterOptions.Json); + } + /// Converts an extensions options instance to an OpenAI options instance. private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { @@ -425,7 +438,15 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) return new ChatCompletionOptions(); } - ChatCompletionOptions result = options.RawRepresentation as ChatCompletionOptions ?? new(); + if (options.RawRepresentation is ChatCompletionOptions result) + { + // Clone the options to avoid modifying the original. + result = CloneRawRepresentation(result); + } + else + { + result = new ChatCompletionOptions(); + } result.FrequencyPenalty ??= options.FrequencyPenalty; result.MaxOutputTokenCount ??= options.MaxOutputTokens; diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index ab72b224e8a..044becc4f38 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -282,6 +282,156 @@ public async Task IChatClient_WithNullModel_ChatOptions_WithNotNullModel_Streami Assert.Equal("Hello! How can I assist you today?", responseText); } + [Fact] + public async Task ChatOptions_DoNotMutateRawRepresentation_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.75, + "max_tokens":10, + "top_p":0.5, + "presence_penalty":0.5, + "temperature":0.5, + "seed":42, + "stop":["hello","world"], + "response_format":{"type":"text"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} + ], + "tool_choice":"auto" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + ChatCompletionsOptions openAIOptions = new(); + ChatOptions chatOptions = new() + { + RawRepresentation = openAIOptions, + FrequencyPenalty = 0.75f, + MaxOutputTokens = 10, + TopP = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, + Seed = 42, + ToolMode = ChatToolMode.Auto, + ResponseFormat = ChatResponseFormat.Text, + Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], + StopSequences = ["hello", "world"] + }; + + var response = await client.GetResponseAsync("hello", chatOptions); + Assert.NotNull(response); + Assert.Equal("Hello! How can I assist you today?", response.Text); + Assert.Same(openAIOptions, chatOptions.RawRepresentation); + Assert.Null(openAIOptions.FrequencyPenalty); + Assert.Null(openAIOptions.MaxTokens); + Assert.Null(openAIOptions.NucleusSamplingFactor); + Assert.Null(openAIOptions.PresencePenalty); + Assert.Null(openAIOptions.Temperature); +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + Assert.Null(openAIOptions.Seed); +#pragma warning restore OPENAI001 + Assert.Null(openAIOptions.ToolChoice); + Assert.Null(openAIOptions.ResponseFormat); + Assert.Empty(openAIOptions.Tools); + Assert.Empty(openAIOptions.StopSequences); + } + + [Fact] + public async Task ChatOptions_DoNotMutateRawRepresentation_Streaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.75, + "max_tokens":10, + "top_p":0.5, + "presence_penalty":0.5, + "temperature":0.5, + "seed":42, + "stop":["hello","world"], + "response_format":{"type":"text"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} + ], + "tool_choice":"auto", + "stream":true + } + """; + + const string Output = """ + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Hello! "}}]} + + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"content":"How can I assist you today?"}}]} + + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]} + + data: [DONE] + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + ChatCompletionsOptions openAIOptions = new(); + ChatOptions chatOptions = new() + { + RawRepresentation = openAIOptions, + FrequencyPenalty = 0.75f, + MaxOutputTokens = 10, + TopP = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, + Seed = 42, + ToolMode = ChatToolMode.Auto, + ResponseFormat = ChatResponseFormat.Text, + Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], + StopSequences = ["hello", "world"] + }; + + string responseText = string.Empty; + await foreach (var update in client.GetStreamingResponseAsync("hello", chatOptions)) + { + responseText += update.Text; + } + + Assert.Equal("Hello! How can I assist you today?", responseText); + Assert.Same(openAIOptions, chatOptions.RawRepresentation); + Assert.Null(openAIOptions.FrequencyPenalty); + Assert.Null(openAIOptions.MaxTokens); + Assert.Null(openAIOptions.NucleusSamplingFactor); + Assert.Null(openAIOptions.PresencePenalty); + Assert.Null(openAIOptions.Temperature); +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + Assert.Null(openAIOptions.Seed); +#pragma warning restore OPENAI001 + Assert.Null(openAIOptions.ToolChoice); + Assert.Null(openAIOptions.ResponseFormat); + Assert.Empty(openAIOptions.Tools); + Assert.Empty(openAIOptions.StopSequences); + } + [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 7d978f4db47..98e9ca5d548 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -276,6 +276,157 @@ public async Task BasicRequestResponse_Streaming() }, usage.Details.AdditionalCounts); } + [Fact] + public async Task ChatOptions_DoNotMutateRawRepresentation_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.75, + "max_completion_tokens":10, + "top_p":0.5, + "presence_penalty":0.5, + "temperature":0.5, + "seed":42, + "stop":["hello","world"], + "response_format":{"type":"text"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"additionalProperties":false,"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} + ], + "tool_choice":"auto" + } + """; + + const string Output = """ + { + "id": "chatcmpl-123", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + ChatCompletionOptions openAIOptions = new(); + ChatOptions chatOptions = new() + { + RawRepresentation = openAIOptions, + FrequencyPenalty = 0.75f, + MaxOutputTokens = 10, + TopP = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, + Seed = 42, + ToolMode = ChatToolMode.Auto, + ResponseFormat = ChatResponseFormat.Text, + Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], + StopSequences = ["hello", "world"] + }; + + var response = await client.GetResponseAsync("hello", chatOptions); + Assert.NotNull(response); + Assert.Equal("Hello! How can I assist you today?", response.Text); + Assert.Same(openAIOptions, chatOptions.RawRepresentation); + Assert.Null(openAIOptions.FrequencyPenalty); + Assert.Null(openAIOptions.MaxOutputTokenCount); + Assert.Null(openAIOptions.TopP); + Assert.Null(openAIOptions.PresencePenalty); + Assert.Null(openAIOptions.Temperature); +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + Assert.Null(openAIOptions.Seed); +#pragma warning restore OPENAI001 + Assert.Null(openAIOptions.ToolChoice); + Assert.Null(openAIOptions.ResponseFormat); + Assert.Empty(openAIOptions.Tools); + Assert.Empty(openAIOptions.StopSequences); + } + + [Fact] + public async Task ChatOptions_DoNotMutateRawRepresentation_Streaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.75, + "max_completion_tokens":10, + "top_p":0.5, + "presence_penalty":0.5, + "temperature":0.5, + "seed":42, + "stop":["hello","world"], + "response_format":{"type":"text"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}},"additionalProperties":false}}} + ], + "tool_choice":"auto", + "stream":true, + "stream_options":{"include_usage":true} + } + """; + + const string Output = """ + data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Hello! "}}]} + + data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{"content":"How can I assist you today?"}}]} + + data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]} + + data: [DONE] + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + ChatCompletionOptions openAIOptions = new(); + ChatOptions chatOptions = new() + { + RawRepresentation = openAIOptions, + FrequencyPenalty = 0.75f, + MaxOutputTokens = 10, + TopP = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, + Seed = 42, + ToolMode = ChatToolMode.Auto, + ResponseFormat = ChatResponseFormat.Text, + Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], + StopSequences = ["hello", "world"] + }; + + string responseText = string.Empty; + await foreach (var update in client.GetStreamingResponseAsync("hello", chatOptions)) + { + responseText += update.Text; + } + + Assert.Equal("Hello! How can I assist you today?", responseText); + Assert.Same(openAIOptions, chatOptions.RawRepresentation); + Assert.Null(openAIOptions.FrequencyPenalty); + Assert.Null(openAIOptions.MaxOutputTokenCount); + Assert.Null(openAIOptions.TopP); + Assert.Null(openAIOptions.PresencePenalty); + Assert.Null(openAIOptions.Temperature); +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + Assert.Null(openAIOptions.Seed); +#pragma warning restore OPENAI001 + Assert.Null(openAIOptions.ToolChoice); + Assert.Null(openAIOptions.ResponseFormat); + Assert.Empty(openAIOptions.Tools); + Assert.Empty(openAIOptions.StopSequences); + } + [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { From be7f38e665ea571ce46e1269e4b8d719d23e6d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Fri, 2 May 2025 11:12:49 -0500 Subject: [PATCH 07/14] Reflection workaround for ToolChoice not being cloned --- .../AzureAIInferenceChatClient.cs | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index bc7d43b5e32..bceb50ac95e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -281,15 +281,36 @@ private ChatCompletionsOptions CreateAzureAIOptions(IEnumerable cha Model = options?.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.") }; - private static ChatCompletionsOptions CloneRawRepresentation(IJsonModel optionsAsModel) + private static ChatCompletionsOptions CloneUsingIJsonModel(ChatCompletionsOptions options) { + IJsonModel optionsAsIJsonModel = options; using MemoryStream ms = new MemoryStream(); using Utf8JsonWriter writer = new Utf8JsonWriter(ms); - optionsAsModel.Write(writer, ModelReaderWriterOptions.Json); + optionsAsIJsonModel.Write(writer, ModelReaderWriterOptions.Json); writer.Flush(); var reader = new Utf8JsonReader(ms.ToArray()); - return optionsAsModel.Create(ref reader, ModelReaderWriterOptions.Json); + ChatCompletionsOptions ret = optionsAsIJsonModel.Create(ref reader, ModelReaderWriterOptions.Json); + + // Workaround for ToolChoice not being cloned. + if (options.ToolChoice != null) + { + FunctionDefinition? toolChoiceFunction = typeof(ChatCompletionsToolChoice) + .GetProperty("Function", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(options.ToolChoice) as FunctionDefinition; + + if (toolChoiceFunction is null) + { + // assume its a preset value e.g. ChatCompletionsToolChoice.Auto + ret.ToolChoice = options.ToolChoice; + } + else + { + ret.ToolChoice = new ChatCompletionsToolChoice(new FunctionDefinition(toolChoiceFunction.Name)); + } + } + + return ret; } /// Converts an extensions options instance to an AzureAI options instance. @@ -304,7 +325,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon { // Clone the options to avoid modifying the original. ////TODO: ToolChoice is not being cloned. - result = CloneRawRepresentation(result); + result = CloneUsingIJsonModel(result); result.Messages = ToAzureAIInferenceChatMessages(chatContents).ToList(); result.Model ??= options.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options."); } From 4f98cd1e95bcf55fa89883bf80dd1f73bbafec1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Fri, 2 May 2025 11:13:49 -0500 Subject: [PATCH 08/14] Style changes --- .../Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs | 8 ++++---- .../AzureAIInferenceChatClientTests.cs | 10 ++++++---- .../OpenAIChatClientTests.cs | 8 ++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index ebdd53000e5..cfd0078f635 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -419,15 +419,15 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple return response; } - private static ChatCompletionOptions CloneRawRepresentation(IJsonModel optionsAsModel) + private static ChatCompletionOptions CloneUsingIJsonModel(IJsonModel optionsAsIJsonModel) { using MemoryStream ms = new MemoryStream(); using Utf8JsonWriter writer = new Utf8JsonWriter(ms); - optionsAsModel.Write(writer, ModelReaderWriterOptions.Json); + optionsAsIJsonModel.Write(writer, ModelReaderWriterOptions.Json); writer.Flush(); var reader = new Utf8JsonReader(ms.ToArray()); - return optionsAsModel.Create(ref reader, ModelReaderWriterOptions.Json); + return optionsAsIJsonModel.Create(ref reader, ModelReaderWriterOptions.Json); } /// Converts an extensions options instance to an OpenAI options instance. @@ -441,7 +441,7 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) if (options.RawRepresentation is ChatCompletionOptions result) { // Clone the options to avoid modifying the original. - result = CloneRawRepresentation(result); + result = CloneUsingIJsonModel(result); } else { diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 044becc4f38..b27050b2549 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -485,11 +485,12 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio PresencePenalty = 0.5f, Temperature = 0.5f, Seed = 42, + ToolChoice = ChatCompletionsToolChoice.Auto, + ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat() }; azureAIOptions.StopSequences.Add("hello"); // this one merges with the other. azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); // this one merges with the other. - azureAIOptions.ToolChoice = ChatCompletionsToolChoice.Auto; - azureAIOptions.ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat(); + ////azureAIOptions.AdditionalProperties["something_else"] = new BinaryData("\"value\""); ChatOptions chatOptions = new ChatOptions { @@ -561,11 +562,12 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio PresencePenalty = 0.5f, Temperature = 0.5f, Seed = 42, + ToolChoice = ChatCompletionsToolChoice.Auto, + ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat() }; azureAIOptions.StopSequences.Add("hello"); // this one merges with the other. azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); // this one merges with the other. - azureAIOptions.ToolChoice = ChatCompletionsToolChoice.Auto; - azureAIOptions.ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat(); + ////azureAIOptions.AdditionalProperties["something_else"] = new BinaryData("\"value\""); ChatOptions chatOptions = new ChatOptions { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 98e9ca5d548..a8a7615284e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -480,11 +480,11 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, #pragma warning restore OPENAI001 + ToolChoice = ChatToolChoice.CreateAutoChoice(), + ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); - openAIOptions.ToolChoice = ChatToolChoice.CreateAutoChoice(); - openAIOptions.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); ChatOptions chatOptions = new() { @@ -557,11 +557,11 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, #pragma warning restore OPENAI001 + ToolChoice = ChatToolChoice.CreateAutoChoice(), + ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); - openAIOptions.ToolChoice = ChatToolChoice.CreateAutoChoice(); - openAIOptions.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); ChatOptions chatOptions = new() { From 066213597398fb94d2ce8308ff41abfbce84066a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Fri, 2 May 2025 11:44:12 -0500 Subject: [PATCH 09/14] AI.Inference: Bring back propagation of additional properties --- .../AzureAIInferenceChatClient.cs | 9 ++++++ .../AzureAIInferenceChatClientTests.cs | 32 ++++++++++++------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index bceb50ac95e..4efd3d6a18f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -349,6 +349,15 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon } } + if (options.AdditionalProperties is { } props) + { + foreach (var prop in props) + { + byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + result.AdditionalProperties[prop.Key] = new BinaryData(data); + } + } + if (options.Tools is { Count: > 0 } tools) { foreach (AITool tool in tools) diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index b27050b2549..75e2cfd7ebe 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -451,7 +451,9 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}}, {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type": "object","required": ["personName"],"properties": {"personName": {"description": "The person whose age is being requested","type": "string"}}}}} ], - "tool_choice":"auto" + "tool_choice":"auto", + "additional_property_from_raw_representation":42, + "additional_property_from_MEAI_options":42 } """; @@ -477,7 +479,6 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ChatCompletionsOptions azureAIOptions = new() { - Messages = [new ChatRequestUserMessage("overwrite me!")], // this one should be overwritten. Model = "gpt-4o-mini", FrequencyPenalty = 0.75f, MaxTokens = 10, @@ -488,9 +489,9 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ToolChoice = ChatCompletionsToolChoice.Auto, ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat() }; - azureAIOptions.StopSequences.Add("hello"); // this one merges with the other. - azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); // this one merges with the other. - ////azureAIOptions.AdditionalProperties["something_else"] = new BinaryData("\"value\""); + azureAIOptions.StopSequences.Add("hello"); + azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); + azureAIOptions.AdditionalProperties["additional_property_from_raw_representation"] = new BinaryData("42"); ChatOptions chatOptions = new ChatOptions { @@ -505,7 +506,11 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio StopSequences = ["world"], Tools = [tool], ToolMode = ChatToolMode.None, - ResponseFormat = ChatResponseFormat.Json + ResponseFormat = ChatResponseFormat.Json, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["additional_property_from_MEAI_options"] = 42 + } }; var response = await client.GetResponseAsync("hello", chatOptions); @@ -533,6 +538,8 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type": "object","required": ["personName"],"properties": {"personName": {"description": "The person whose age is being requested","type": "string"}}}}} ], "tool_choice":"auto", + "additional_property_from_raw_representation":42, + "additional_property_from_MEAI_options":42, "stream":true } """; @@ -554,7 +561,6 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ChatCompletionsOptions azureAIOptions = new() { - Messages = [new ChatRequestUserMessage("overwrite me!")], // this one should be overwritten. Model = "gpt-4o-mini", FrequencyPenalty = 0.75f, MaxTokens = 10, @@ -565,9 +571,9 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ToolChoice = ChatCompletionsToolChoice.Auto, ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat() }; - azureAIOptions.StopSequences.Add("hello"); // this one merges with the other. - azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); // this one merges with the other. - ////azureAIOptions.AdditionalProperties["something_else"] = new BinaryData("\"value\""); + azureAIOptions.StopSequences.Add("hello"); + azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); + azureAIOptions.AdditionalProperties["additional_property_from_raw_representation"] = new BinaryData("42"); ChatOptions chatOptions = new ChatOptions { @@ -582,7 +588,11 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio StopSequences = ["world"], Tools = [tool], ToolMode = ChatToolMode.None, - ResponseFormat = ChatResponseFormat.Json + ResponseFormat = ChatResponseFormat.Json, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["additional_property_from_MEAI_options"] = 42 + } }; string responseText = string.Empty; From 66059682115a0638cc6aeaa684b66de5f7bb340a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Fri, 2 May 2025 12:06:53 -0500 Subject: [PATCH 10/14] Don't use 0.1f, it doesn't roundtrip properly in .NET Framework --- .../AzureAIInferenceChatClientTests.cs | 48 +++++++++---------- .../OpenAIChatClientTests.cs | 48 +++++++++---------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 75e2cfd7ebe..25ad7d79886 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -497,11 +497,11 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio { RawRepresentation = azureAIOptions, ModelId = null, - FrequencyPenalty = 0.1f, + FrequencyPenalty = 0.125f, MaxOutputTokens = 1, - TopP = 0.1f, - PresencePenalty = 0.1f, - Temperature = 0.1f, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, Seed = 1, StopSequences = ["world"], Tools = [tool], @@ -579,11 +579,11 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio { RawRepresentation = azureAIOptions, ModelId = null, - FrequencyPenalty = 0.1f, + FrequencyPenalty = 0.125f, MaxOutputTokens = 1, - TopP = 0.1f, - PresencePenalty = 0.1f, - Temperature = 0.1f, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, Seed = 1, StopSequences = ["world"], Tools = [tool], @@ -611,11 +611,11 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr { "messages":[{"role":"user","content":"hello"}], "model":"gpt-4o-mini", - "frequency_penalty":0.1, + "frequency_penalty":0.125, "max_tokens":1, - "top_p":0.1, - "presence_penalty":0.1, - "temperature":0.1, + "top_p":0.125, + "presence_penalty":0.125, + "temperature":0.125, "seed":1, "stop":["world"], "response_format":{"type":"json_object"}, @@ -664,11 +664,11 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr { RawRepresentation = azureAIOptions, ModelId = "gpt-4o-mini", - FrequencyPenalty = 0.1f, + FrequencyPenalty = 0.125f, MaxOutputTokens = 1, - TopP = 0.1f, - PresencePenalty = 0.1f, - Temperature = 0.1f, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, Seed = 1, StopSequences = ["world"], Tools = [tool], @@ -688,11 +688,11 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream { "messages":[{"role":"user","content":"hello"}], "model":"gpt-4o-mini", - "frequency_penalty":0.1, + "frequency_penalty":0.125, "max_tokens":1, - "top_p":0.1, - "presence_penalty":0.1, - "temperature":0.1, + "top_p":0.125, + "presence_penalty":0.125, + "temperature":0.125, "seed":1, "stop":["world"], "response_format":{"type":"json_object"}, @@ -737,11 +737,11 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream { RawRepresentation = azureAIOptions, ModelId = "gpt-4o-mini", - FrequencyPenalty = 0.1f, + FrequencyPenalty = 0.125f, MaxOutputTokens = 1, - TopP = 0.1f, - PresencePenalty = 0.1f, - Temperature = 0.1f, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, Seed = 1, StopSequences = ["world"], Tools = [tool], diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index a8a7615284e..0e807e9838a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -490,11 +490,11 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio { RawRepresentation = openAIOptions, ModelId = null, - FrequencyPenalty = 0.1f, + FrequencyPenalty = 0.125f, MaxOutputTokens = 1, - TopP = 0.1f, - PresencePenalty = 0.1f, - Temperature = 0.1f, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, Seed = 1, StopSequences = ["world"], Tools = [tool], @@ -567,11 +567,11 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio { RawRepresentation = openAIOptions, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. - FrequencyPenalty = 0.1f, + FrequencyPenalty = 0.125f, MaxOutputTokens = 1, - TopP = 0.1f, - PresencePenalty = 0.1f, - Temperature = 0.1f, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, Seed = 1, StopSequences = ["world"], Tools = [tool], @@ -595,11 +595,11 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr { "messages":[{"role":"user","content":"hello"}], "model":"gpt-4o-mini", - "frequency_penalty":0.1, + "frequency_penalty":0.125, "max_completion_tokens":1, - "top_p":0.1, - "presence_penalty":0.1, - "temperature":0.1, + "top_p":0.125, + "presence_penalty":0.125, + "temperature":0.125, "seed":1, "stop":["world"], "response_format":{"type":"json_object"}, @@ -648,11 +648,11 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr { RawRepresentation = openAIOptions, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. - FrequencyPenalty = 0.1f, + FrequencyPenalty = 0.125f, MaxOutputTokens = 1, - TopP = 0.1f, - PresencePenalty = 0.1f, - Temperature = 0.1f, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, Seed = 1, StopSequences = ["world"], Tools = [tool], @@ -672,11 +672,11 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream { "messages":[{"role":"user","content":"hello"}], "model":"gpt-4o-mini", - "frequency_penalty":0.1, + "frequency_penalty":0.125, "max_completion_tokens":1, - "top_p":0.1, - "presence_penalty":0.1, - "temperature":0.1, + "top_p":0.125, + "presence_penalty":0.125, + "temperature":0.125, "seed":1, "stop":["world"], "response_format":{"type":"json_object"}, @@ -722,11 +722,11 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream { RawRepresentation = openAIOptions, ModelId = null, - FrequencyPenalty = 0.1f, + FrequencyPenalty = 0.125f, MaxOutputTokens = 1, - TopP = 0.1f, - PresencePenalty = 0.1f, - Temperature = 0.1f, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, Seed = 1, StopSequences = ["world"], Tools = [tool], From ceb8c9061d31d86805e1d80a4a51f7f0a5103ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Wed, 7 May 2025 12:30:21 -0500 Subject: [PATCH 11/14] Add RawRepresentationFactory instead of object? property --- .../ChatCompletion/ChatOptions.cs | 19 +- .../AzureAIInferenceChatClient.cs | 45 +-- .../OpenAIChatClient.cs | 23 +- .../ChatCompletion/ChatOptionsTests.cs | 17 +- .../AzureAIInferenceChatClientTests.cs | 284 +++++----------- .../OpenAIChatClientTests.cs | 307 +++++------------- 6 files changed, 190 insertions(+), 505 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index 24bff75267a..f1582d041fa 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -119,7 +120,7 @@ public string? ChatThreadId public IList? Tools { get; set; } /// - /// Gets or sets the raw representation of the chat options from an underlying implementation. + /// Gets or sets a callback responsible of creating the raw representation of the chat options from an underlying implementation. /// /// /// The underlying implementation may have its own representation of options. @@ -127,15 +128,15 @@ public string? ChatThreadId /// is invoked with a , that implementation may convert the provided options into /// its own representation in order to use it while performing the operation. For situations where a consumer knows /// which concrete is being used and how it represents options, an instance of that - /// implementation-specific options type may be stored into this property, for - /// the implementation to use instead of creating a new instance. Such implementations - /// may mutate the supplied options instance further based on other settings supplied on this - /// instance or from other inputs, like the enumerable of s. This is typically used - /// in order to set an implementation-specific setting that isn't otherwise exposed from the strongly-typed properties - /// on . + /// implementation-specific options type may be returned by this callback, for the + /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options + /// instance further based on other settings supplied on this instance or from other inputs, + /// like the enumerable of s. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly-typed + /// properties on . /// [JsonIgnore] - public object? RawRepresentation { get; set; } + public Func? RawRepresentationFactory { get; set; } /// Gets or sets any additional properties associated with the options. public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } @@ -163,7 +164,7 @@ public virtual ChatOptions Clone() ModelId = ModelId, AllowMultipleToolCalls = AllowMultipleToolCalls, ToolMode = ToolMode, - RawRepresentation = RawRepresentation, + RawRepresentationFactory = RawRepresentationFactory, AdditionalProperties = AdditionalProperties?.Clone(), }; diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 4efd3d6a18f..2476656a8d4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -2,10 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -278,41 +276,10 @@ private static ChatRole ToChatRole(global::Azure.AI.Inference.ChatRole role) => private ChatCompletionsOptions CreateAzureAIOptions(IEnumerable chatContents, ChatOptions? options) => new(ToAzureAIInferenceChatMessages(chatContents)) { - Model = options?.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.") + Model = options?.ModelId ?? _metadata.DefaultModelId ?? + throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.") }; - private static ChatCompletionsOptions CloneUsingIJsonModel(ChatCompletionsOptions options) - { - IJsonModel optionsAsIJsonModel = options; - using MemoryStream ms = new MemoryStream(); - using Utf8JsonWriter writer = new Utf8JsonWriter(ms); - optionsAsIJsonModel.Write(writer, ModelReaderWriterOptions.Json); - writer.Flush(); - - var reader = new Utf8JsonReader(ms.ToArray()); - ChatCompletionsOptions ret = optionsAsIJsonModel.Create(ref reader, ModelReaderWriterOptions.Json); - - // Workaround for ToolChoice not being cloned. - if (options.ToolChoice != null) - { - FunctionDefinition? toolChoiceFunction = typeof(ChatCompletionsToolChoice) - .GetProperty("Function", BindingFlags.NonPublic | BindingFlags.Instance)! - .GetValue(options.ToolChoice) as FunctionDefinition; - - if (toolChoiceFunction is null) - { - // assume its a preset value e.g. ChatCompletionsToolChoice.Auto - ret.ToolChoice = options.ToolChoice; - } - else - { - ret.ToolChoice = new ChatCompletionsToolChoice(new FunctionDefinition(toolChoiceFunction.Name)); - } - } - - return ret; - } - /// Converts an extensions options instance to an AzureAI options instance. private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatContents, ChatOptions? options) { @@ -321,13 +288,11 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon return CreateAzureAIOptions(chatContents, options); } - if (options.RawRepresentation is ChatCompletionsOptions result) + if (options.RawRepresentationFactory?.Invoke(this) is ChatCompletionsOptions result) { - // Clone the options to avoid modifying the original. - ////TODO: ToolChoice is not being cloned. - result = CloneUsingIJsonModel(result); result.Messages = ToAzureAIInferenceChatMessages(chatContents).ToList(); - result.Model ??= options.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options."); + result.Model ??= options.ModelId ?? _metadata.DefaultModelId ?? + throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options."); } else { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index cfd0078f635..a2cf8484c8c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -2,9 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.ClientModel.Primitives; using System.Collections.Generic; -using System.IO; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -20,6 +18,7 @@ #pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) #pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; @@ -419,31 +418,15 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple return response; } - private static ChatCompletionOptions CloneUsingIJsonModel(IJsonModel optionsAsIJsonModel) - { - using MemoryStream ms = new MemoryStream(); - using Utf8JsonWriter writer = new Utf8JsonWriter(ms); - optionsAsIJsonModel.Write(writer, ModelReaderWriterOptions.Json); - writer.Flush(); - - var reader = new Utf8JsonReader(ms.ToArray()); - return optionsAsIJsonModel.Create(ref reader, ModelReaderWriterOptions.Json); - } - /// Converts an extensions options instance to an OpenAI options instance. - private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) + private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { if (options is null) { return new ChatCompletionOptions(); } - if (options.RawRepresentation is ChatCompletionOptions result) - { - // Clone the options to avoid modifying the original. - result = CloneUsingIJsonModel(result); - } - else + if (options.RawRepresentationFactory?.Invoke(this) is not ChatCompletionOptions result) { result = new ChatCompletionOptions(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs index 213e634164d..cdf1aab09c9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using System.Text.Json; using Xunit; @@ -28,7 +29,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(options.ToolMode); Assert.Null(options.Tools); Assert.Null(options.AdditionalProperties); - Assert.Null(options.RawRepresentation); + Assert.Null(options.RawRepresentationFactory); ChatOptions clone = options.Clone(); Assert.Null(clone.ConversationId); @@ -46,7 +47,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(clone.ToolMode); Assert.Null(clone.Tools); Assert.Null(clone.AdditionalProperties); - Assert.Null(clone.RawRepresentation); + Assert.Null(clone.RawRepresentationFactory); } [Fact] @@ -71,7 +72,7 @@ public void Properties_Roundtrip() ["key"] = "value", }; - object rawRepresentation = new(); + Func rawRepresentationFactory = (c) => null; options.ConversationId = "12345"; options.Temperature = 0.1f; @@ -87,7 +88,7 @@ public void Properties_Roundtrip() options.AllowMultipleToolCalls = true; options.ToolMode = ChatToolMode.RequireAny; options.Tools = tools; - options.RawRepresentation = rawRepresentation; + options.RawRepresentationFactory = rawRepresentationFactory; options.AdditionalProperties = additionalProps; Assert.Equal("12345", options.ConversationId); @@ -104,7 +105,7 @@ public void Properties_Roundtrip() Assert.True(options.AllowMultipleToolCalls); Assert.Same(ChatToolMode.RequireAny, options.ToolMode); Assert.Same(tools, options.Tools); - Assert.Same(rawRepresentation, options.RawRepresentation); + Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); Assert.Same(additionalProps, options.AdditionalProperties); ChatOptions clone = options.Clone(); @@ -122,7 +123,7 @@ public void Properties_Roundtrip() Assert.True(clone.AllowMultipleToolCalls); Assert.Same(ChatToolMode.RequireAny, clone.ToolMode); Assert.Equal(tools, clone.Tools); - Assert.Same(rawRepresentation, clone.RawRepresentation); + Assert.Same(rawRepresentationFactory, clone.RawRepresentationFactory); Assert.Equal(additionalProps, clone.AdditionalProperties); } @@ -160,7 +161,7 @@ public void JsonSerialization_Roundtrips() AIFunctionFactory.Create(() => 42), AIFunctionFactory.Create(() => 43), ]; - options.RawRepresentation = new object(); + options.RawRepresentationFactory = (c) => null; options.AdditionalProperties = additionalProps; string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.ChatOptions); @@ -183,7 +184,7 @@ public void JsonSerialization_Roundtrips() Assert.False(deserialized.AllowMultipleToolCalls); Assert.Equal(ChatToolMode.RequireAny, deserialized.ToolMode); Assert.Null(deserialized.Tools); - Assert.Null(deserialized.RawRepresentation); + Assert.Null(deserialized.RawRepresentationFactory); Assert.NotNull(deserialized.AdditionalProperties); Assert.Single(deserialized.AdditionalProperties); diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 25ad7d79886..0e46a781da9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -282,156 +282,6 @@ public async Task IChatClient_WithNullModel_ChatOptions_WithNotNullModel_Streami Assert.Equal("Hello! How can I assist you today?", responseText); } - [Fact] - public async Task ChatOptions_DoNotMutateRawRepresentation_NonStreaming() - { - const string Input = """ - { - "messages":[{"role":"user","content":"hello"}], - "model":"gpt-4o-mini", - "frequency_penalty":0.75, - "max_tokens":10, - "top_p":0.5, - "presence_penalty":0.5, - "temperature":0.5, - "seed":42, - "stop":["hello","world"], - "response_format":{"type":"text"}, - "tools":[ - {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} - ], - "tool_choice":"auto" - } - """; - - const string Output = """ - { - "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", - "object": "chat.completion", - "choices": [ - { - "message": { - "role": "assistant", - "content": "Hello! How can I assist you today?" - } - } - ] - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); - - ChatCompletionsOptions openAIOptions = new(); - ChatOptions chatOptions = new() - { - RawRepresentation = openAIOptions, - FrequencyPenalty = 0.75f, - MaxOutputTokens = 10, - TopP = 0.5f, - PresencePenalty = 0.5f, - Temperature = 0.5f, - Seed = 42, - ToolMode = ChatToolMode.Auto, - ResponseFormat = ChatResponseFormat.Text, - Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], - StopSequences = ["hello", "world"] - }; - - var response = await client.GetResponseAsync("hello", chatOptions); - Assert.NotNull(response); - Assert.Equal("Hello! How can I assist you today?", response.Text); - Assert.Same(openAIOptions, chatOptions.RawRepresentation); - Assert.Null(openAIOptions.FrequencyPenalty); - Assert.Null(openAIOptions.MaxTokens); - Assert.Null(openAIOptions.NucleusSamplingFactor); - Assert.Null(openAIOptions.PresencePenalty); - Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 - Assert.Null(openAIOptions.ToolChoice); - Assert.Null(openAIOptions.ResponseFormat); - Assert.Empty(openAIOptions.Tools); - Assert.Empty(openAIOptions.StopSequences); - } - - [Fact] - public async Task ChatOptions_DoNotMutateRawRepresentation_Streaming() - { - const string Input = """ - { - "messages":[{"role":"user","content":"hello"}], - "model":"gpt-4o-mini", - "frequency_penalty":0.75, - "max_tokens":10, - "top_p":0.5, - "presence_penalty":0.5, - "temperature":0.5, - "seed":42, - "stop":["hello","world"], - "response_format":{"type":"text"}, - "tools":[ - {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} - ], - "tool_choice":"auto", - "stream":true - } - """; - - const string Output = """ - data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Hello! "}}]} - - data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"content":"How can I assist you today?"}}]} - - data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]} - - data: [DONE] - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); - - ChatCompletionsOptions openAIOptions = new(); - ChatOptions chatOptions = new() - { - RawRepresentation = openAIOptions, - FrequencyPenalty = 0.75f, - MaxOutputTokens = 10, - TopP = 0.5f, - PresencePenalty = 0.5f, - Temperature = 0.5f, - Seed = 42, - ToolMode = ChatToolMode.Auto, - ResponseFormat = ChatResponseFormat.Text, - Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], - StopSequences = ["hello", "world"] - }; - - string responseText = string.Empty; - await foreach (var update in client.GetStreamingResponseAsync("hello", chatOptions)) - { - responseText += update.Text; - } - - Assert.Equal("Hello! How can I assist you today?", responseText); - Assert.Same(openAIOptions, chatOptions.RawRepresentation); - Assert.Null(openAIOptions.FrequencyPenalty); - Assert.Null(openAIOptions.MaxTokens); - Assert.Null(openAIOptions.NucleusSamplingFactor); - Assert.Null(openAIOptions.PresencePenalty); - Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 - Assert.Null(openAIOptions.ToolChoice); - Assert.Null(openAIOptions.ResponseFormat); - Assert.Empty(openAIOptions.Tools); - Assert.Empty(openAIOptions.StopSequences); - } - [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { @@ -495,7 +345,25 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ChatOptions chatOptions = new ChatOptions { - RawRepresentation = azureAIOptions, + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new() + { + Model = "gpt-4o-mini", + FrequencyPenalty = 0.75f, + MaxTokens = 10, + NucleusSamplingFactor = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, + Seed = 42, + ToolChoice = ChatCompletionsToolChoice.Auto, + ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat() + }; + azureAIOptions.StopSequences.Add("hello"); + azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); + azureAIOptions.AdditionalProperties["additional_property_from_raw_representation"] = new BinaryData("42"); + return azureAIOptions; + }, ModelId = null, FrequencyPenalty = 0.125f, MaxOutputTokens = 1, @@ -559,25 +427,27 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio using IChatClient client = CreateChatClient(httpClient, modelId: null!); AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); - ChatCompletionsOptions azureAIOptions = new() - { - Model = "gpt-4o-mini", - FrequencyPenalty = 0.75f, - MaxTokens = 10, - NucleusSamplingFactor = 0.5f, - PresencePenalty = 0.5f, - Temperature = 0.5f, - Seed = 42, - ToolChoice = ChatCompletionsToolChoice.Auto, - ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat() - }; - azureAIOptions.StopSequences.Add("hello"); - azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); - azureAIOptions.AdditionalProperties["additional_property_from_raw_representation"] = new BinaryData("42"); - ChatOptions chatOptions = new ChatOptions { - RawRepresentation = azureAIOptions, + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new() + { + Model = "gpt-4o-mini", + FrequencyPenalty = 0.75f, + MaxTokens = 10, + NucleusSamplingFactor = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, + Seed = 42, + ToolChoice = ChatCompletionsToolChoice.Auto, + ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat() + }; + azureAIOptions.StopSequences.Add("hello"); + azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); + azureAIOptions.AdditionalProperties["additional_property_from_raw_representation"] = new BinaryData("42"); + return azureAIOptions; + }, ModelId = null, FrequencyPenalty = 0.125f, MaxOutputTokens = 1, @@ -646,23 +516,25 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr using IChatClient client = CreateChatClient(httpClient, modelId: null!); AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); - ChatCompletionsOptions azureAIOptions = new(); - Assert.Empty(azureAIOptions.Messages); - Assert.Null(azureAIOptions.Model); - Assert.Null(azureAIOptions.FrequencyPenalty); - Assert.Null(azureAIOptions.MaxTokens); - Assert.Null(azureAIOptions.NucleusSamplingFactor); - Assert.Null(azureAIOptions.PresencePenalty); - Assert.Null(azureAIOptions.Temperature); - Assert.Null(azureAIOptions.Seed); - Assert.Empty(azureAIOptions.StopSequences); - Assert.Empty(azureAIOptions.Tools); - Assert.Null(azureAIOptions.ToolChoice); - Assert.Null(azureAIOptions.ResponseFormat); - ChatOptions chatOptions = new ChatOptions { - RawRepresentation = azureAIOptions, + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new(); + Assert.Empty(azureAIOptions.Messages); + Assert.Null(azureAIOptions.Model); + Assert.Null(azureAIOptions.FrequencyPenalty); + Assert.Null(azureAIOptions.MaxTokens); + Assert.Null(azureAIOptions.NucleusSamplingFactor); + Assert.Null(azureAIOptions.PresencePenalty); + Assert.Null(azureAIOptions.Temperature); + Assert.Null(azureAIOptions.Seed); + Assert.Empty(azureAIOptions.StopSequences); + Assert.Empty(azureAIOptions.Tools); + Assert.Null(azureAIOptions.ToolChoice); + Assert.Null(azureAIOptions.ResponseFormat); + return azureAIOptions; + }, ModelId = "gpt-4o-mini", FrequencyPenalty = 0.125f, MaxOutputTokens = 1, @@ -719,23 +591,25 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream using IChatClient client = CreateChatClient(httpClient, modelId: null!); AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); - ChatCompletionsOptions azureAIOptions = new(); - Assert.Empty(azureAIOptions.Messages); - Assert.Null(azureAIOptions.Model); - Assert.Null(azureAIOptions.FrequencyPenalty); - Assert.Null(azureAIOptions.MaxTokens); - Assert.Null(azureAIOptions.NucleusSamplingFactor); - Assert.Null(azureAIOptions.PresencePenalty); - Assert.Null(azureAIOptions.Temperature); - Assert.Null(azureAIOptions.Seed); - Assert.Empty(azureAIOptions.StopSequences); - Assert.Empty(azureAIOptions.Tools); - Assert.Null(azureAIOptions.ToolChoice); - Assert.Null(azureAIOptions.ResponseFormat); - ChatOptions chatOptions = new ChatOptions { - RawRepresentation = azureAIOptions, + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new(); + Assert.Empty(azureAIOptions.Messages); + Assert.Null(azureAIOptions.Model); + Assert.Null(azureAIOptions.FrequencyPenalty); + Assert.Null(azureAIOptions.MaxTokens); + Assert.Null(azureAIOptions.NucleusSamplingFactor); + Assert.Null(azureAIOptions.PresencePenalty); + Assert.Null(azureAIOptions.Temperature); + Assert.Null(azureAIOptions.Seed); + Assert.Empty(azureAIOptions.StopSequences); + Assert.Empty(azureAIOptions.Tools); + Assert.Null(azureAIOptions.ToolChoice); + Assert.Null(azureAIOptions.ResponseFormat); + return azureAIOptions; + }, ModelId = "gpt-4o-mini", FrequencyPenalty = 0.125f, MaxOutputTokens = 1, @@ -823,11 +697,6 @@ public async Task AdditionalOptions_NonStreaming() using HttpClient httpClient = new(handler); using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); - ChatCompletionsOptions azureAIOptions = new(); - azureAIOptions.AdditionalProperties.Add("top_k", new BinaryData(JsonSerializer.SerializeToUtf8Bytes(40, typeof(object)))); - azureAIOptions.AdditionalProperties.Add("something_else", new BinaryData(JsonSerializer.SerializeToUtf8Bytes("value1", typeof(object)))); - azureAIOptions.AdditionalProperties.Add("and_something_further", new BinaryData(JsonSerializer.SerializeToUtf8Bytes(123, typeof(object)))); - Assert.NotNull(await client.GetResponseAsync("hello", new() { MaxOutputTokens = 10, @@ -837,7 +706,14 @@ public async Task AdditionalOptions_NonStreaming() PresencePenalty = 0.5f, Seed = 42, StopSequences = ["yes", "no"], - RawRepresentation = azureAIOptions, + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new(); + azureAIOptions.AdditionalProperties.Add("top_k", new BinaryData(JsonSerializer.SerializeToUtf8Bytes(40, typeof(object)))); + azureAIOptions.AdditionalProperties.Add("something_else", new BinaryData(JsonSerializer.SerializeToUtf8Bytes("value1", typeof(object)))); + azureAIOptions.AdditionalProperties.Add("and_something_further", new BinaryData(JsonSerializer.SerializeToUtf8Bytes(123, typeof(object)))); + return azureAIOptions; + }, })); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 0e807e9838a..9ba9c743166 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -276,157 +276,6 @@ public async Task BasicRequestResponse_Streaming() }, usage.Details.AdditionalCounts); } - [Fact] - public async Task ChatOptions_DoNotMutateRawRepresentation_NonStreaming() - { - const string Input = """ - { - "messages":[{"role":"user","content":"hello"}], - "model":"gpt-4o-mini", - "frequency_penalty":0.75, - "max_completion_tokens":10, - "top_p":0.5, - "presence_penalty":0.5, - "temperature":0.5, - "seed":42, - "stop":["hello","world"], - "response_format":{"type":"text"}, - "tools":[ - {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"additionalProperties":false,"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} - ], - "tool_choice":"auto" - } - """; - - const string Output = """ - { - "id": "chatcmpl-123", - "object": "chat.completion", - "choices": [ - { - "message": { - "role": "assistant", - "content": "Hello! How can I assist you today?" - } - } - ] - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); - - ChatCompletionOptions openAIOptions = new(); - ChatOptions chatOptions = new() - { - RawRepresentation = openAIOptions, - FrequencyPenalty = 0.75f, - MaxOutputTokens = 10, - TopP = 0.5f, - PresencePenalty = 0.5f, - Temperature = 0.5f, - Seed = 42, - ToolMode = ChatToolMode.Auto, - ResponseFormat = ChatResponseFormat.Text, - Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], - StopSequences = ["hello", "world"] - }; - - var response = await client.GetResponseAsync("hello", chatOptions); - Assert.NotNull(response); - Assert.Equal("Hello! How can I assist you today?", response.Text); - Assert.Same(openAIOptions, chatOptions.RawRepresentation); - Assert.Null(openAIOptions.FrequencyPenalty); - Assert.Null(openAIOptions.MaxOutputTokenCount); - Assert.Null(openAIOptions.TopP); - Assert.Null(openAIOptions.PresencePenalty); - Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 - Assert.Null(openAIOptions.ToolChoice); - Assert.Null(openAIOptions.ResponseFormat); - Assert.Empty(openAIOptions.Tools); - Assert.Empty(openAIOptions.StopSequences); - } - - [Fact] - public async Task ChatOptions_DoNotMutateRawRepresentation_Streaming() - { - const string Input = """ - { - "messages":[{"role":"user","content":"hello"}], - "model":"gpt-4o-mini", - "frequency_penalty":0.75, - "max_completion_tokens":10, - "top_p":0.5, - "presence_penalty":0.5, - "temperature":0.5, - "seed":42, - "stop":["hello","world"], - "response_format":{"type":"text"}, - "tools":[ - {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}},"additionalProperties":false}}} - ], - "tool_choice":"auto", - "stream":true, - "stream_options":{"include_usage":true} - } - """; - - const string Output = """ - data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Hello! "}}]} - - data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{"content":"How can I assist you today?"}}]} - - data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]} - - data: [DONE] - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); - - ChatCompletionOptions openAIOptions = new(); - ChatOptions chatOptions = new() - { - RawRepresentation = openAIOptions, - FrequencyPenalty = 0.75f, - MaxOutputTokens = 10, - TopP = 0.5f, - PresencePenalty = 0.5f, - Temperature = 0.5f, - Seed = 42, - ToolMode = ChatToolMode.Auto, - ResponseFormat = ChatResponseFormat.Text, - Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], - StopSequences = ["hello", "world"] - }; - - string responseText = string.Empty; - await foreach (var update in client.GetStreamingResponseAsync("hello", chatOptions)) - { - responseText += update.Text; - } - - Assert.Equal("Hello! How can I assist you today?", responseText); - Assert.Same(openAIOptions, chatOptions.RawRepresentation); - Assert.Null(openAIOptions.FrequencyPenalty); - Assert.Null(openAIOptions.MaxOutputTokenCount); - Assert.Null(openAIOptions.TopP); - Assert.Null(openAIOptions.PresencePenalty); - Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 - Assert.Null(openAIOptions.ToolChoice); - Assert.Null(openAIOptions.ResponseFormat); - Assert.Empty(openAIOptions.Tools); - Assert.Empty(openAIOptions.StopSequences); - } - [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { @@ -470,25 +319,27 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio using IChatClient client = CreateChatClient(httpClient, modelId: "gpt-4o-mini"); AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); - ChatCompletionOptions openAIOptions = new() + ChatOptions chatOptions = new() { - FrequencyPenalty = 0.75f, - MaxOutputTokenCount = 10, - TopP = 0.5f, - PresencePenalty = 0.5f, - Temperature = 0.5f, + RawRepresentationFactory = (c) => + { + ChatCompletionOptions openAIOptions = new() + { + FrequencyPenalty = 0.75f, + MaxOutputTokenCount = 10, + TopP = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - Seed = 42, + Seed = 42, #pragma warning restore OPENAI001 - ToolChoice = ChatToolChoice.CreateAutoChoice(), - ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() - }; - openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); - - ChatOptions chatOptions = new() - { - RawRepresentation = openAIOptions, + ToolChoice = ChatToolChoice.CreateAutoChoice(), + ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() + }; + openAIOptions.StopSequences.Add("hello"); + openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + return openAIOptions; + }, ModelId = null, FrequencyPenalty = 0.125f, MaxOutputTokens = 1, @@ -547,25 +398,27 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio using IChatClient client = CreateChatClient(httpClient, modelId: "gpt-4o-mini"); AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); - ChatCompletionOptions openAIOptions = new() + ChatOptions chatOptions = new() { - FrequencyPenalty = 0.75f, - MaxOutputTokenCount = 10, - TopP = 0.5f, - PresencePenalty = 0.5f, - Temperature = 0.5f, + RawRepresentationFactory = (c) => + { + ChatCompletionOptions openAIOptions = new() + { + FrequencyPenalty = 0.75f, + MaxOutputTokenCount = 10, + TopP = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, #pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - Seed = 42, + Seed = 42, #pragma warning restore OPENAI001 - ToolChoice = ChatToolChoice.CreateAutoChoice(), - ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() - }; - openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); - - ChatOptions chatOptions = new() - { - RawRepresentation = openAIOptions, + ToolChoice = ChatToolChoice.CreateAutoChoice(), + ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() + }; + openAIOptions.StopSequences.Add("hello"); + openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + return openAIOptions; + }, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. FrequencyPenalty = 0.125f, MaxOutputTokens = 1, @@ -630,23 +483,25 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr using IChatClient client = CreateChatClient(httpClient, modelId: "gpt-4o-mini"); AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); - ChatCompletionOptions openAIOptions = new(); - Assert.Null(openAIOptions.FrequencyPenalty); - Assert.Null(openAIOptions.MaxOutputTokenCount); - Assert.Null(openAIOptions.TopP); - Assert.Null(openAIOptions.PresencePenalty); - Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 - Assert.Empty(openAIOptions.StopSequences); - Assert.Empty(openAIOptions.Tools); - Assert.Null(openAIOptions.ToolChoice); - Assert.Null(openAIOptions.ResponseFormat); - ChatOptions chatOptions = new() { - RawRepresentation = openAIOptions, + RawRepresentationFactory = (c) => + { + ChatCompletionOptions openAIOptions = new(); + Assert.Null(openAIOptions.FrequencyPenalty); + Assert.Null(openAIOptions.MaxOutputTokenCount); + Assert.Null(openAIOptions.TopP); + Assert.Null(openAIOptions.PresencePenalty); + Assert.Null(openAIOptions.Temperature); +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + Assert.Null(openAIOptions.Seed); +#pragma warning restore OPENAI001 + Assert.Empty(openAIOptions.StopSequences); + Assert.Empty(openAIOptions.Tools); + Assert.Null(openAIOptions.ToolChoice); + Assert.Null(openAIOptions.ResponseFormat); + return openAIOptions; + }, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. FrequencyPenalty = 0.125f, MaxOutputTokens = 1, @@ -704,23 +559,25 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream using IChatClient client = CreateChatClient(httpClient, modelId: "gpt-4o-mini"); AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); - ChatCompletionOptions openAIOptions = new(); - Assert.Null(openAIOptions.FrequencyPenalty); - Assert.Null(openAIOptions.MaxOutputTokenCount); - Assert.Null(openAIOptions.TopP); - Assert.Null(openAIOptions.PresencePenalty); - Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 - Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 - Assert.Empty(openAIOptions.StopSequences); - Assert.Empty(openAIOptions.Tools); - Assert.Null(openAIOptions.ToolChoice); - Assert.Null(openAIOptions.ResponseFormat); - ChatOptions chatOptions = new() { - RawRepresentation = openAIOptions, + RawRepresentationFactory = (c) => + { + ChatCompletionOptions openAIOptions = new(); + Assert.Null(openAIOptions.FrequencyPenalty); + Assert.Null(openAIOptions.MaxOutputTokenCount); + Assert.Null(openAIOptions.TopP); + Assert.Null(openAIOptions.PresencePenalty); + Assert.Null(openAIOptions.Temperature); +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + Assert.Null(openAIOptions.Seed); +#pragma warning restore OPENAI001 + Assert.Empty(openAIOptions.StopSequences); + Assert.Empty(openAIOptions.Tools); + Assert.Null(openAIOptions.ToolChoice); + Assert.Null(openAIOptions.ResponseFormat); + return openAIOptions; + }, ModelId = null, FrequencyPenalty = 0.125f, MaxOutputTokens = 1, @@ -811,20 +668,22 @@ public async Task StronglyTypedOptions_AllSent() using HttpClient httpClient = new(handler); using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); - var openAIOptions = new ChatCompletionOptions - { - StoredOutputEnabled = true, - IncludeLogProbabilities = true, - TopLogProbabilityCount = 42, - EndUserId = "12345", - }; - openAIOptions.Metadata.Add("something", "else"); - openAIOptions.LogitBiases.Add(12, 34); - Assert.NotNull(await client.GetResponseAsync("hello", new() { AllowMultipleToolCalls = false, - RawRepresentation = openAIOptions + RawRepresentationFactory = (c) => + { + var openAIOptions = new ChatCompletionOptions + { + StoredOutputEnabled = true, + IncludeLogProbabilities = true, + TopLogProbabilityCount = 42, + EndUserId = "12345", + }; + openAIOptions.Metadata.Add("something", "else"); + openAIOptions.LogitBiases.Add(12, 34); + return openAIOptions; + }, })); } From 7d07876e4f8563a494cdedd1ddb8528f7676c27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Wed, 7 May 2025 12:38:50 -0500 Subject: [PATCH 12/14] Augment remarks to discourage returning shared instances --- .../ChatCompletion/ChatOptions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index f1582d041fa..f44ed1fc082 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -131,7 +131,8 @@ public string? ChatThreadId /// implementation-specific options type may be returned by this callback, for the /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options /// instance further based on other settings supplied on this instance or from other inputs, - /// like the enumerable of s. + /// like the enumerable of s, therefore, its **strongly recommended** to not return shared instances + /// and instead make the callback return a new instance per each call. /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly-typed /// properties on . /// From c273c7f429e8b45323e54224c925e97cd3f51c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Thu, 8 May 2025 13:11:50 -0500 Subject: [PATCH 13/14] Documentation feedback --- .../ChatCompletion/ChatOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index f44ed1fc082..8cbd0a3e20a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -127,7 +127,7 @@ public string? ChatThreadId /// When or /// is invoked with a , that implementation may convert the provided options into /// its own representation in order to use it while performing the operation. For situations where a consumer knows - /// which concrete is being used and how it represents options, an instance of that + /// which concrete is being used and how it represents options, a new instance of that /// implementation-specific options type may be returned by this callback, for the /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options /// instance further based on other settings supplied on this instance or from other inputs, From aa8a9d2d71698b0b4a041b8847c3fe97a35a98fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Thu, 8 May 2025 15:47:36 -0500 Subject: [PATCH 14/14] AI.Inference: keep passing TopK as AdditionalProperty if not already there --- .../AzureAIInferenceChatClient.cs | 6 ++ .../AzureAIInferenceChatClientTests.cs | 60 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 9b894484486..ff62845eb0e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -322,6 +322,12 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon } } + // This property is strongly typed on ChatOptions but not on ChatCompletionsOptions. + if (options.TopK is int topK && !result.AdditionalProperties.ContainsKey("top_k")) + { + result.AdditionalProperties["top_k"] = new BinaryData(JsonSerializer.SerializeToUtf8Bytes(topK, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(int)))); + } + if (options.AdditionalProperties is { } props) { foreach (var prop in props) diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 0e46a781da9..26cd380ec83 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -702,6 +702,66 @@ public async Task AdditionalOptions_NonStreaming() MaxOutputTokens = 10, Temperature = 0.5f, TopP = 0.5f, + TopK = 40, + FrequencyPenalty = 0.75f, + PresencePenalty = 0.5f, + Seed = 42, + StopSequences = ["yes", "no"], + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new(); + azureAIOptions.AdditionalProperties.Add("something_else", new BinaryData(JsonSerializer.SerializeToUtf8Bytes("value1", typeof(object)))); + azureAIOptions.AdditionalProperties.Add("and_something_further", new BinaryData(JsonSerializer.SerializeToUtf8Bytes(123, typeof(object)))); + return azureAIOptions; + }, + })); + } + + [Fact] + public async Task TopK_DoNotOverwrite_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user", "content":"hello"}], + "max_tokens":10, + "temperature":0.5, + "top_p":0.5, + "stop":["yes","no"], + "presence_penalty":0.5, + "frequency_penalty":0.75, + "seed":42, + "model":"gpt-4o-mini", + "top_k":40, + "something_else":"value1", + "and_something_further":123 + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.NotNull(await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 10, + Temperature = 0.5f, + TopP = 0.5f, + TopK = 20, // will be ignored because the raw representation already specifies it. FrequencyPenalty = 0.75f, PresencePenalty = 0.5f, Seed = 42,