diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index ac5fba99ae5..f2eeffe9dbf 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; @@ -121,6 +122,26 @@ public string? ChatThreadId [JsonIgnore] public IList? Tools { get; set; } + /// + /// 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. + /// 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, 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, + /// 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 . + /// + [JsonIgnore] + public Func? RawRepresentationFactory { get; set; } + /// Gets or sets any additional properties associated with the options. public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } @@ -147,6 +168,7 @@ public virtual ChatOptions Clone() ModelId = ModelId, AllowMultipleToolCalls = AllowMultipleToolCalls, ToolMode = ToolMode, + 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 97d239aaefc..ff62845eb0e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -281,66 +281,74 @@ 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.RawRepresentationFactory?.Invoke(this) is ChatCompletionsOptions 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."); + } + else + { + result = CreateAzureAIOptions(chatContents, options); + } - if (options is not null) + 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) { - 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) { - foreach (string stopSequence in stopSequences) - { - result.StopSequences.Add(stopSequence); - } + result.StopSequences.Add(stopSequence); } + } + + // 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)))); + } - // These properties are strongly typed on ChatOptions but not on ChatCompletionsOptions. - if (options.TopK is int topK) + if (options.AdditionalProperties is { } props) + { + foreach (var prop in props) { - result.AdditionalProperties["top_k"] = new BinaryData(JsonSerializer.SerializeToUtf8Bytes(topK, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(int)))); + byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + result.AdditionalProperties[prop.Key] = new BinaryData(data); } + } - 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) + if (result.ToolChoice is null && result.Tools.Count > 0) { - foreach (AITool tool in tools) - { - if (tool is AIFunction af) - { - result.Tools.Add(ToAzureAIChatTool(af)); - } - } - switch (options.ToolMode) { case NoneChatToolMode: @@ -359,7 +367,10 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon break; } } + } + if (result.ResponseFormat is null) + { if (options.ResponseFormat is ChatResponseFormatText) { result.ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat(); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 93cd20a70eb..c644bd77f21 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -18,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; @@ -259,7 +260,6 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha 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)) @@ -270,7 +270,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() @@ -284,22 +283,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 }) { @@ -382,12 +365,6 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha responseUpdate.Contents.Add(new ErrorContent(refusal.ToString()) { ErrorCode = "Refusal" }); } - // Propagate additional relevant metadata. - if (fingerprint is not null) - { - (responseUpdate.AdditionalProperties ??= [])[nameof(ChatCompletion.SystemFingerprint)] = fingerprint; - } - yield return responseUpdate; } } @@ -426,20 +403,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); } @@ -480,140 +444,74 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple response.Usage = FromOpenAIUsage(tokenUsage); } - if (openAICompletion.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) - { - (response.AdditionalProperties ??= [])[nameof(openAICompletion.ContentTokenLogProbabilities)] = contentTokenLogProbs; - } - - if (openAICompletion.RefusalTokenLogProbabilities is { Count: > 0 } refusalTokenLogProbs) - { - (response.AdditionalProperties ??= [])[nameof(openAICompletion.RefusalTokenLogProbabilities)] = refusalTokenLogProbs; - } - - if (openAICompletion.SystemFingerprint is string systemFingerprint) - { - (response.AdditionalProperties ??= [])[nameof(openAICompletion.SystemFingerprint)] = systemFingerprint; - } - return response; } /// Converts an extensions options instance to an OpenAI options instance. - private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) + private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { - ChatCompletionOptions result = new(); + if (options is null) + { + return new ChatCompletionOptions(); + } - if (options is not null) + if (options.RawRepresentationFactory?.Invoke(this) is not ChatCompletionOptions result) { - 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 = 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; #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)) + if (tool is AIFunction af) { - 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)) - { - 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.ToolChoice is null && 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 (result.ResponseFormat is null) + { if (options.ResponseFormat is ChatResponseFormatText) { result.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); 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..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,6 +29,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(options.ToolMode); Assert.Null(options.Tools); Assert.Null(options.AdditionalProperties); + Assert.Null(options.RawRepresentationFactory); ChatOptions clone = options.Clone(); Assert.Null(clone.ConversationId); @@ -45,6 +47,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(clone.ToolMode); Assert.Null(clone.Tools); Assert.Null(clone.AdditionalProperties); + Assert.Null(clone.RawRepresentationFactory); } [Fact] @@ -69,6 +72,8 @@ public void Properties_Roundtrip() ["key"] = "value", }; + Func rawRepresentationFactory = (c) => null; + options.ConversationId = "12345"; options.Temperature = 0.1f; options.MaxOutputTokens = 2; @@ -83,6 +88,7 @@ public void Properties_Roundtrip() options.AllowMultipleToolCalls = true; options.ToolMode = ChatToolMode.RequireAny; options.Tools = tools; + options.RawRepresentationFactory = rawRepresentationFactory; options.AdditionalProperties = additionalProps; Assert.Equal("12345", options.ConversationId); @@ -99,6 +105,7 @@ public void Properties_Roundtrip() Assert.True(options.AllowMultipleToolCalls); Assert.Same(ChatToolMode.RequireAny, options.ToolMode); Assert.Same(tools, options.Tools); + Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); Assert.Same(additionalProps, options.AdditionalProperties); ChatOptions clone = options.Clone(); @@ -116,6 +123,7 @@ public void Properties_Roundtrip() Assert.True(clone.AllowMultipleToolCalls); Assert.Same(ChatToolMode.RequireAny, clone.ToolMode); Assert.Equal(tools, clone.Tools); + Assert.Same(rawRepresentationFactory, clone.RawRepresentationFactory); Assert.Equal(additionalProps, clone.AdditionalProperties); } @@ -153,6 +161,7 @@ public void JsonSerialization_Roundtrips() AIFunctionFactory.Create(() => 42), AIFunctionFactory.Create(() => 43), ]; + options.RawRepresentationFactory = (c) => null; options.AdditionalProperties = additionalProps; string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.ChatOptions); @@ -175,6 +184,7 @@ public void JsonSerialization_Roundtrips() Assert.False(deserialized.AllowMultipleToolCalls); Assert.Equal(ChatToolMode.RequireAny, deserialized.ToolMode); Assert.Null(deserialized.Tools); + 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 788b8568607..26cd380ec83 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,420 @@ [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", + "additional_property_from_raw_representation":42, + "additional_property_from_MEAI_options":42 + } + """; + + 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() + { + 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 + { + 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, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["additional_property_from_MEAI_options"] = 42 + } + }; + + 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", + "additional_property_from_raw_representation":42, + "additional_property_from_MEAI_options":42, + "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."); + + ChatOptions chatOptions = new ChatOptions + { + 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, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["additional_property_from_MEAI_options"] = 42 + } + }; + + 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.125, + "max_tokens":1, + "top_p":0.125, + "presence_penalty":0.125, + "temperature":0.125, + "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."); + + ChatOptions chatOptions = new ChatOptions + { + 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, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + 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.125, + "max_tokens":1, + "top_p":0.125, + "presence_penalty":0.125, + "temperature":0.125, + "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."); + + ChatOptions chatOptions = new ChatOptions + { + 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, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + 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() { @@ -279,10 +707,72 @@ public async Task AdditionalOptions_NonStreaming() PresencePenalty = 0.5f, Seed = 42, StopSequences = ["yes", "no"], - AdditionalProperties = new() + 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, + StopSequences = ["yes", "no"], + RawRepresentationFactory = (c) => { - ["something_else"] = "value1", - ["and_something_further"] = 123, + 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.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj index e129c6cf26f..a0f9abaf589 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 4fdc36b5280..9ba9c743166 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; @@ -184,9 +186,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 +256,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 +277,361 @@ public async Task BasicRequestResponse_Streaming() } [Fact] - public async Task NonStronglyTypedOptions_AllSent() + 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."); + + ChatOptions chatOptions = new() + { + 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, +#pragma warning restore OPENAI001 + 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, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + 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."); + + ChatOptions chatOptions = new() + { + 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, +#pragma warning restore OPENAI001 + 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, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + 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.125, + "max_completion_tokens":1, + "top_p":0.125, + "presence_penalty":0.125, + "temperature":0.125, + "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."); + + ChatOptions chatOptions = new() + { + 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, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + 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.125, + "max_completion_tokens":1, + "top_p":0.125, + "presence_penalty":0.125, + "temperature":0.125, + "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."); + + ChatOptions chatOptions = new() + { + 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, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + 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() { const string Input = """ { @@ -320,17 +671,18 @@ public async Task NonStronglyTypedOptions_AllSent() Assert.NotNull(await client.GetResponseAsync("hello", new() { AllowMultipleToolCalls = false, - AdditionalProperties = new() + RawRepresentationFactory = (c) => { - ["StoredOutputEnabled"] = true, - ["Metadata"] = new Dictionary + var openAIOptions = new ChatCompletionOptions { - ["something"] = "else", - }, - ["LogitBiases"] = new Dictionary { { 12, 34 } }, - ["IncludeLogProbabilities"] = true, - ["TopLogProbabilityCount"] = 42, - ["EndUserId"] = "12345", + StoredOutputEnabled = true, + IncludeLogProbabilities = true, + TopLogProbabilityCount = 42, + EndUserId = "12345", + }; + openAIOptions.Metadata.Add("something", "else"); + openAIOptions.LogitBiases.Add(12, 34); + return openAIOptions; }, })); } @@ -446,9 +798,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 +895,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 +993,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 +1110,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 +1192,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 +1283,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 +1446,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 +1561,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) =>