diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs index 027817ddff8..12b43543c03 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs @@ -34,7 +34,7 @@ public ChatClientMetadata(string? providerName = null, Uri? providerUri = null, /// Gets the ID of the default model used by this chat client. /// - /// This value can be null if no default model is set on the corresponding . + /// This value can be if no default model is set on the corresponding . /// An individual request may override this value via . /// public string? DefaultModelId { get; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs index 970a833477c..18b3ec658e3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs @@ -14,12 +14,12 @@ namespace Microsoft.Extensions.AI; [JsonConverter(typeof(Converter))] public readonly struct ChatFinishReason : IEquatable { - /// The finish reason value. If null because `default(ChatFinishReason)` was used, the instance will behave like . + /// The finish reason value. If because `default(ChatFinishReason)` was used, the instance will behave like . private readonly string? _value; /// Initializes a new instance of the struct with a string that describes the reason. /// The reason value. - /// is null. + /// is . /// is empty or composed entirely of whitespace. [JsonConstructor] public ChatFinishReason(string value) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index d457c934f97..071254182c2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -62,7 +62,7 @@ public class ChatOptions /// Gets or sets the response format for the chat request. /// /// - /// If null, no response format is specified and the client will use its default. + /// If , no response format is specified and the client will use its default. /// This property can be set to to specify that the response should be unstructured text, /// to to specify that the response should be structured JSON data, or /// an instance of constructed with a specific JSON schema to request that the diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs index 673c2c51474..51f99f4d2c4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs @@ -32,7 +32,7 @@ public ChatResponseFormatJson( SchemaDescription = schemaDescription; } - /// Gets the JSON schema associated with the response, or null if there is none. + /// Gets the JSON schema associated with the response, or if there is none. public JsonElement? Schema { get; } /// Gets a name for the schema. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs index c4f324de9a8..88e1bedb6e9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs @@ -54,7 +54,7 @@ public ChatRole(string value) /// /// The first instance to compare. /// The second instance to compare. - /// if left and right are both null or have equivalent values; otherwise, . + /// if left and right are both or have equivalent values; otherwise, . public static bool operator ==(ChatRole left, ChatRole right) { return left.Equals(right); @@ -66,7 +66,7 @@ public ChatRole(string value) /// /// The first instance to compare. /// The second instance to compare. - /// if left and right have different values; if they have equivalent values or are both null. + /// if left and right have different values; if they have equivalent values or are both . public static bool operator !=(ChatRole left, ChatRole right) { return !(left == right); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs index dc0c5db9289..6353586208f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -110,7 +110,7 @@ public DataContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? /// /// The byte contents. /// The media type (also known as MIME type) represented by the content. - /// is null. + /// is . /// is empty or composed entirely of whitespace. public DataContent(ReadOnlyMemory data, string mediaType) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs index 5f5f9d8c5c2..6c3503b4227 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs @@ -36,14 +36,14 @@ public EmbeddingGeneratorMetadata(string? providerName = null, Uri? providerUri /// Gets the ID of the default model used by this embedding generator. /// - /// This value can be null if no default model is set on the corresponding embedding generator. + /// This value can be if no default model is set on the corresponding embedding generator. /// An individual request may override this value via . /// public string? DefaultModelId { get; } /// Gets the number of dimensions in the embeddings produced by the default model. /// - /// This value can be null if either the number of dimensions is unknown or there are multiple possible lengths associated with this model. + /// This value can be if either the number of dimensions is unknown or there are multiple possible lengths associated with this model. /// An individual request may override this value via . /// public int? DefaultModelDimensions { get; } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index c4ccafe098e..c78b910378c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -22,7 +22,7 @@ namespace Microsoft.Extensions.AI; /// Represents an for an Azure AI Inference . -public sealed class AzureAIInferenceChatClient : IChatClient +internal sealed class AzureAIInferenceChatClient : IChatClient { /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -30,24 +30,21 @@ public sealed class AzureAIInferenceChatClient : IChatClient /// The underlying . private readonly ChatCompletionsClient _chatCompletionsClient; - /// The use for any serialization activities related to tool call arguments and results. - private JsonSerializerOptions _toolCallJsonSerializerOptions = AIJsonUtilities.DefaultOptions; - /// Gets a ChatRole.Developer value. private static ChatRole ChatRoleDeveloper { get; } = new("developer"); /// Initializes a new instance of the class for the specified . /// The underlying client. - /// The ID of the model to use. If null, it can be provided per request via . + /// The ID of the model to use. If , it can be provided per request via . /// is . - /// is empty or composed entirely of whitespace. - public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, string? modelId = null) + /// is empty or composed entirely of whitespace. + public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, string? defaultModelId = null) { _ = Throw.IfNull(chatCompletionsClient); - if (modelId is not null) + if (defaultModelId is not null) { - _ = Throw.IfNullOrWhitespace(modelId); + _ = Throw.IfNullOrWhitespace(defaultModelId); } _chatCompletionsClient = chatCompletionsClient; @@ -59,14 +56,7 @@ public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, s var providerUrl = typeof(ChatCompletionsClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(chatCompletionsClient) as Uri; - _metadata = new ChatClientMetadata("az.ai.inference", providerUrl, modelId); - } - - /// Gets or sets to use for any serialization activities related to tool call arguments and results. - public JsonSerializerOptions ToolCallJsonSerializerOptions - { - get => _toolCallJsonSerializerOptions; - set => _toolCallJsonSerializerOptions = Throw.IfNull(value); + _metadata = new ChatClientMetadata("az.ai.inference", providerUrl, defaultModelId); } /// @@ -324,7 +314,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon default: if (prop.Value is not null) { - byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); + byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); result.AdditionalProperties[prop.Key] = new BinaryData(data); } @@ -413,7 +403,7 @@ private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunc } /// Converts an Extensions chat message enumerable to an AzureAI chat message enumerable. - private IEnumerable ToAzureAIInferenceChatMessages(IEnumerable inputs) + private static IEnumerable ToAzureAIInferenceChatMessages(IEnumerable inputs) { // Maps all of the M.E.AI types to the corresponding AzureAI types. // Unrecognized or non-processable content is ignored. @@ -439,7 +429,7 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab { try { - result = JsonSerializer.Serialize(resultContent.Result, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); + result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -482,7 +472,7 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab callRequest.CallId, new FunctionCall( callRequest.Name, - JsonSerializer.Serialize(callRequest.Arguments, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(IDictionary)))))); + JsonSerializer.Serialize(callRequest.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs index b3fadc7bf54..9721befca0b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs @@ -22,7 +22,7 @@ namespace Microsoft.Extensions.AI; /// Represents an for an Azure.AI.Inference . -public sealed class AzureAIInferenceEmbeddingGenerator : +internal sealed class AzureAIInferenceEmbeddingGenerator : IEmbeddingGenerator> { /// Metadata about the embedding generator. @@ -36,31 +36,31 @@ public sealed class AzureAIInferenceEmbeddingGenerator : /// Initializes a new instance of the class. /// The underlying client. - /// + /// /// The ID of the model to use. This can also be overridden per request via . /// Either this parameter or must provide a valid model ID. /// - /// The number of dimensions to generate in each embedding. + /// The number of dimensions to generate in each embedding. /// is . - /// is empty or composed entirely of whitespace. - /// is not positive. + /// is empty or composed entirely of whitespace. + /// is not positive. public AzureAIInferenceEmbeddingGenerator( - EmbeddingsClient embeddingsClient, string? modelId = null, int? dimensions = null) + EmbeddingsClient embeddingsClient, string? defaultModelId = null, int? defaultModelDimensions = null) { _ = Throw.IfNull(embeddingsClient); - if (modelId is not null) + if (defaultModelId is not null) { - _ = Throw.IfNullOrWhitespace(modelId); + _ = Throw.IfNullOrWhitespace(defaultModelId); } - if (dimensions is < 1) + if (defaultModelDimensions is < 1) { - Throw.ArgumentOutOfRangeException(nameof(dimensions), "Value must be greater than 0."); + Throw.ArgumentOutOfRangeException(nameof(defaultModelDimensions), "Value must be greater than 0."); } _embeddingsClient = embeddingsClient; - _dimensions = dimensions; + _dimensions = defaultModelDimensions; // https://github.com/Azure/azure-sdk-for-net/issues/46278 // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages @@ -69,7 +69,7 @@ public AzureAIInferenceEmbeddingGenerator( var providerUrl = typeof(EmbeddingsClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(embeddingsClient) as Uri; - _metadata = new EmbeddingGeneratorMetadata("az.ai.inference", providerUrl, modelId, dimensions); + _metadata = new EmbeddingGeneratorMetadata("az.ai.inference", providerUrl, defaultModelId, defaultModelDimensions); } /// diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs index 117a416b30a..58739b00b0d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs @@ -10,18 +10,18 @@ public static class AzureAIInferenceExtensions { /// Gets an for use with this . /// The client. - /// The ID of the model to use. If null, it can be provided per request via . + /// The ID of the model to use. If , it can be provided per request via . /// An that can be used to converse via the . - public static IChatClient AsChatClient( + public static IChatClient AsIChatClient( this ChatCompletionsClient chatCompletionsClient, string? modelId = null) => new AzureAIInferenceChatClient(chatCompletionsClient, modelId); /// Gets an for use with this . /// The client. - /// The ID of the model to use. If null, it can be provided per request via . - /// The number of dimensions to generate in each embedding. + /// The ID of the model to use. If , it can be provided per request via . + /// The number of dimensions generated in each embedding. /// An that can be used to generate embeddings via the . - public static IEmbeddingGenerator> AsEmbeddingGenerator( - this EmbeddingsClient embeddingsClient, string? modelId = null, int? dimensions = null) => - new AzureAIInferenceEmbeddingGenerator(embeddingsClient, modelId, dimensions); + public static IEmbeddingGenerator> AsIEmbeddingGenerator( + this EmbeddingsClient embeddingsClient, string? defaultModelId = null, int? defaultModelDimensions = null) => + new AzureAIInferenceEmbeddingGenerator(embeddingsClient, defaultModelId, defaultModelDimensions); } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md index d8cd5a424ea..2c2ad628c53 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md @@ -30,7 +30,7 @@ IChatClient client = new Azure.AI.Inference.ChatCompletionsClient( new("https://models.inference.ai.azure.com"), new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) - .AsChatClient("gpt-4o-mini"); + .AsIChatClient("gpt-4o-mini"); Console.WriteLine(await client.GetResponseAsync("What is AI?")); ``` @@ -52,7 +52,7 @@ IChatClient client = new Azure.AI.Inference.ChatCompletionsClient( new("https://models.inference.ai.azure.com"), new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) - .AsChatClient("gpt-4o-mini"); + .AsIChatClient("gpt-4o-mini"); Console.WriteLine(await client.GetResponseAsync( [ @@ -71,7 +71,7 @@ IChatClient client = new Azure.AI.Inference.ChatCompletionsClient( new("https://models.inference.ai.azure.com"), new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) - .AsChatClient("gpt-4o-mini"); + .AsIChatClient("gpt-4o-mini"); await foreach (var update in client.GetStreamingResponseAsync("What is AI?")) { @@ -90,7 +90,7 @@ IChatClient azureClient = new Azure.AI.Inference.ChatCompletionsClient( new("https://models.inference.ai.azure.com"), new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) - .AsChatClient("gpt-4o-mini"); + .AsIChatClient("gpt-4o-mini"); IChatClient client = new ChatClientBuilder(azureClient) .UseFunctionInvocation() @@ -125,7 +125,7 @@ IChatClient azureClient = new Azure.AI.Inference.ChatCompletionsClient( new("https://models.inference.ai.azure.com"), new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) - .AsChatClient("gpt-4o-mini"); + .AsIChatClient("gpt-4o-mini"); IChatClient client = new ChatClientBuilder(azureClient) .UseDistributedCache(cache) @@ -161,7 +161,7 @@ IChatClient azureClient = new Azure.AI.Inference.ChatCompletionsClient( new("https://models.inference.ai.azure.com"), new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) - .AsChatClient("gpt-4o-mini"); + .AsIChatClient("gpt-4o-mini"); IChatClient client = new ChatClientBuilder(azureClient) .UseOpenTelemetry(sourceName: sourceName, configure: c => c.EnableSensitiveData = true) @@ -201,7 +201,7 @@ IChatClient azureClient = new Azure.AI.Inference.ChatCompletionsClient( new("https://models.inference.ai.azure.com"), new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) - .AsChatClient("gpt-4o-mini"); + .AsIChatClient("gpt-4o-mini"); IChatClient client = new ChatClientBuilder(azureClient) .UseDistributedCache(cache) @@ -243,7 +243,7 @@ builder.Services.AddSingleton( builder.Services.AddDistributedMemoryCache(); builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); -builder.Services.AddChatClient(services => services.GetRequiredService().AsChatClient("gpt-4o-mini")) +builder.Services.AddChatClient(services => services.GetRequiredService().AsIChatClient("gpt-4o-mini")) .UseDistributedCache() .UseLogging(); @@ -268,7 +268,7 @@ builder.Services.AddSingleton(new ChatCompletionsClient( new AzureKeyCredential(builder.Configuration["GH_TOKEN"]!))); builder.Services.AddChatClient(services => - services.GetRequiredService().AsChatClient("gpt-4o-mini")); + services.GetRequiredService().AsIChatClient("gpt-4o-mini")); var app = builder.Build(); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/JsonModelHelpers.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/JsonModelHelpers.cs deleted file mode 100644 index 53188b333ee..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/JsonModelHelpers.cs +++ /dev/null @@ -1,38 +0,0 @@ -// 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.ClientModel.Primitives; -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -/// -/// Defines a set of helper methods for working with types. -/// -internal static class JsonModelHelpers -{ - public static BinaryData Serialize(TModel value) - where TModel : IJsonModel - { - return value.Write(ModelReaderWriterOptions.Json); - } - - public static TModel Deserialize(BinaryData data) - where TModel : IJsonModel, new() - { - return JsonModelDeserializationWitness.Value.Create(data, ModelReaderWriterOptions.Json); - } - - public static TModel Deserialize(ref Utf8JsonReader reader) - where TModel : IJsonModel, new() - { - return JsonModelDeserializationWitness.Value.Create(ref reader, ModelReaderWriterOptions.Json); - } - - private sealed class JsonModelDeserializationWitness - where TModel : IJsonModel, new() - { - public static readonly IJsonModel Value = new TModel(); - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantClient.cs deleted file mode 100644 index d838a98ccbb..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantClient.cs +++ /dev/null @@ -1,369 +0,0 @@ -// 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.Linq; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; -using OpenAI; -using OpenAI.Assistants; -using OpenAI.Chat; - -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable CA1031 // Do not catch general exception types -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S1751 // Loops with at most one iteration should be refactored -#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 -#pragma warning disable SA1108 // Block statements should not contain embedded comments - -namespace Microsoft.Extensions.AI; - -/// Represents an for an OpenAI or . -internal sealed class OpenAIAssistantClient : IChatClient -{ - /// Metadata for the client. - private readonly ChatClientMetadata _metadata; - - /// The underlying . - private readonly AssistantClient _assistantClient; - - /// The ID of the assistant to use. - private readonly string _assistantId; - - /// The thread ID to use if none is supplied in . - private readonly string? _threadId; - - /// Initializes a new instance of the class for the specified . - /// The underlying client. - /// The ID of the assistant to use. - /// - /// The ID of the thread to use. If not supplied here, it should be supplied per request in . - /// If none is supplied, a new thread will be created for a request. - /// - public OpenAIAssistantClient(AssistantClient assistantClient, string assistantId, string? threadId) - { - _assistantClient = Throw.IfNull(assistantClient); - _assistantId = Throw.IfNull(assistantId); - _threadId = threadId; - - _metadata = new("openai"); - } - - /// - public object? GetService(Type serviceType, object? serviceKey = null) - { - _ = Throw.IfNull(serviceType); - - return - serviceKey is not null ? null : - serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType == typeof(AssistantClient) ? _assistantClient : - serviceType.IsInstanceOfType(this) ? this : - null; - } - - /// - public Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => - GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken); - - /// - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // Extract necessary state from messages and options. - (RunCreationOptions runOptions, List? toolResults) = CreateRunOptions(messages, options); - - // Get the thread ID. - string? threadId = options?.ChatThreadId ?? _threadId; - if (threadId is null && toolResults is not null) - { - Throw.ArgumentException(nameof(messages), "No thread ID was provided, but chat messages includes tool results."); - } - - // Get the updates to process from the assistant. If we have any tool results, this means submitting those and ignoring - // our runOptions. Otherwise, create a run, and a thread if we don't have one. - IAsyncEnumerable updates; - if (GetRunId(toolResults, out List? toolOutputs) is string existingRunId) - { - updates = _assistantClient.SubmitToolOutputsToRunStreamingAsync(threadId, existingRunId, toolOutputs, cancellationToken); - } - else if (threadId is null) - { - ThreadCreationOptions creationOptions = new(); - foreach (var message in runOptions.AdditionalMessages) - { - creationOptions.InitialMessages.Add(message); - } - - runOptions.AdditionalMessages.Clear(); - - updates = _assistantClient.CreateThreadAndRunStreamingAsync(_assistantId, creationOptions, runOptions, cancellationToken: cancellationToken); - } - else - { - updates = _assistantClient.CreateRunStreamingAsync(threadId, _assistantId, runOptions, cancellationToken); - } - - // Process each update. - string? responseId = null; - await foreach (var update in updates.ConfigureAwait(false)) - { - switch (update) - { - case MessageContentUpdate mcu: - yield return new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text) - { - ChatThreadId = threadId, - RawRepresentation = mcu, - ResponseId = responseId, - MessageId = responseId, // There's no per-message ID, but there's only one mesage per run, so use the run ID - }; - break; - - case ThreadUpdate tu when options is not null: - threadId ??= tu.Value.Id; - break; - - case RunUpdate ru: - threadId ??= ru.Value.ThreadId; - responseId ??= ru.Value.Id; - - ChatResponseUpdate ruUpdate = new() - { - AuthorName = ru.Value.AssistantId, - ChatThreadId = threadId, - CreatedAt = ru.Value.CreatedAt, - ModelId = ru.Value.Model, - RawRepresentation = ru, - ResponseId = responseId, - MessageId = responseId, // There's no per-message ID, but there's only one mesage per run, so use the run ID - Role = ChatRole.Assistant, - }; - - if (ru.Value.Usage is { } usage) - { - ruUpdate.Contents.Add(new UsageContent(new() - { - InputTokenCount = usage.InputTokenCount, - OutputTokenCount = usage.OutputTokenCount, - TotalTokenCount = usage.TotalTokenCount, - })); - } - - if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName) - { - ruUpdate.Contents.Add( - new FunctionCallContent( - JsonSerializer.Serialize(new[] { ru.Value.Id, toolCallId }, OpenAIJsonContext.Default.StringArray!), - functionName, - JsonSerializer.Deserialize(rau.FunctionArguments, OpenAIJsonContext.Default.IDictionaryStringObject)!)); - } - - yield return ruUpdate; - break; - } - } - } - - /// - void IDisposable.Dispose() - { - // Nothing to dispose. Implementation required for the IChatClient interface. - } - - /// Adds the provided messages to the thread and returns the options to use for the request. - private static (RunCreationOptions RunOptions, List? ToolResults) CreateRunOptions( - IEnumerable messages, ChatOptions? options) - { - _ = Throw.IfNull(messages); - - RunCreationOptions runOptions = new(); - - // Handle ChatOptions. - if (options is not null) - { - // Propagate the simple properties that have a 1:1 correspondence. - runOptions.MaxOutputTokenCount = options.MaxOutputTokens; - runOptions.ModelOverride = options.ModelId; - runOptions.NucleusSamplingFactor = options.TopP; - runOptions.Temperature = options.Temperature; - - // Propagate additional properties from AdditionalProperties. - if (options.AdditionalProperties?.TryGetValue(nameof(RunCreationOptions.AllowParallelToolCalls), out bool allowParallelToolCalls) is true) - { - runOptions.AllowParallelToolCalls = allowParallelToolCalls; - } - - if (options.AdditionalProperties?.TryGetValue(nameof(RunCreationOptions.MaxInputTokenCount), out int maxInputTokenCount) is true) - { - runOptions.MaxInputTokenCount = maxInputTokenCount; - } - - if (options.AdditionalProperties?.TryGetValue(nameof(RunCreationOptions.TruncationStrategy), out RunTruncationStrategy? truncationStrategy) is true) - { - runOptions.TruncationStrategy = truncationStrategy; - } - - // Store all the tools to use. - if (options.Tools is { Count: > 0 } tools) - { - foreach (AITool tool in tools) - { - switch (tool) - { - case AIFunction aiFunction: - // Default strict to true, but allow to be overridden by an additional Strict property. - bool strict = - !aiFunction.AdditionalProperties.TryGetValue("Strict", out object? strictObj) || - strictObj is not bool strictValue || - strictValue; - - var functionParameters = BinaryData.FromBytes( - JsonSerializer.SerializeToUtf8Bytes( - JsonSerializer.Deserialize(aiFunction.JsonSchema, OpenAIJsonContext.Default.OpenAIChatToolJson)!, - OpenAIJsonContext.Default.OpenAIChatToolJson)); - - runOptions.ToolsOverride.Add(ToolDefinition.CreateFunction(aiFunction.Name, aiFunction.Description, functionParameters, strict)); - break; - - case HostedCodeInterpreterTool: - runOptions.ToolsOverride.Add(ToolDefinition.CreateCodeInterpreter()); - break; - } - } - } - - // Store the tool mode. - switch (options.ToolMode) - { - case NoneChatToolMode: - runOptions.ToolConstraint = ToolConstraint.None; - break; - - case null: - case AutoChatToolMode: - runOptions.ToolConstraint = ToolConstraint.Auto; - break; - - case RequiredChatToolMode required: - runOptions.ToolConstraint = required.RequiredFunctionName is null ? - new ToolConstraint(ToolDefinition.CreateFunction(required.RequiredFunctionName)) : - ToolConstraint.Required; - break; - } - - // Store the response format. - if (options.ResponseFormat is ChatResponseFormatText) - { - runOptions.ResponseFormat = AssistantResponseFormat.Text; - } - else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) - { - runOptions.ResponseFormat = jsonFormat.Schema is { } jsonSchema ? - AssistantResponseFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription, - strictSchemaEnabled: true) : - AssistantResponseFormat.JsonObject; - } - } - - // Handle ChatMessages. System messages are turned into additional instructions. - StringBuilder? instructions = null; - List? functionResults = null; - foreach (var chatMessage in messages) - { - List messageContents = []; - - if (chatMessage.Role == ChatRole.System || - chatMessage.Role == OpenAIModelMappers.ChatRoleDeveloper) - { - instructions ??= new(); - foreach (var textContent in chatMessage.Contents.OfType()) - { - _ = instructions.Append(textContent); - } - - continue; - } - - foreach (AIContent content in chatMessage.Contents) - { - switch (content) - { - case TextContent tc: - messageContents.Add(MessageContent.FromText(tc.Text)); - break; - - case DataContent dc when dc.HasTopLevelMediaType("image"): - messageContents.Add(MessageContent.FromImageUri(new(dc.Uri))); - break; - - case FunctionResultContent frc: - (functionResults ??= []).Add(frc); - break; - } - } - - if (messageContents.Count > 0) - { - runOptions.AdditionalMessages.Add(new( - chatMessage.Role == ChatRole.Assistant ? MessageRole.Assistant : MessageRole.User, - messageContents)); - } - } - - if (instructions is not null) - { - runOptions.AdditionalInstructions = instructions.ToString(); - } - - return (runOptions, functionResults); - } - - private static string? GetRunId(List? toolResults, out List? toolOutputs) - { - string? runId = null; - toolOutputs = null; - if (toolResults?.Count > 0) - { - foreach (var frc in toolResults) - { - // When creating the FunctionCallContext, we created it with a CallId == [runId, callId]. - // We need to extract the run ID and ensure that the ToolOutput we send back to OpenAI - // is only the call ID. - string[]? runAndCallIDs; - try - { - runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, OpenAIJsonContext.Default.StringArray); - } - catch - { - continue; - } - - if (runAndCallIDs is null || - runAndCallIDs.Length != 2 || - string.IsNullOrWhiteSpace(runAndCallIDs[0]) || // run ID - string.IsNullOrWhiteSpace(runAndCallIDs[1]) || // call ID - (runId is not null && runId != runAndCallIDs[0])) - { - continue; - } - - runId = runAndCallIDs[0]; - (toolOutputs ??= []).Add(new(runAndCallIDs[1], frc.Result?.ToString() ?? string.Empty)); - } - } - - return runId; - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index c85b202f7e8..43a2e21c9e0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -4,61 +4,35 @@ using System; using System.Collections.Generic; using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; using OpenAI; using OpenAI.Chat; +#pragma warning disable CA1308 // Normalize strings to uppercase +#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) #pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable 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 -#pragma warning disable SA1108 // Block statements should not contain embedded comments namespace Microsoft.Extensions.AI; /// Represents an for an OpenAI or . -public sealed class OpenAIChatClient : IChatClient +internal sealed partial class OpenAIChatClient : IChatClient { /// Gets the default OpenAI endpoint. - internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); + private static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); /// Metadata about the client. private readonly ChatClientMetadata _metadata; - /// The underlying . - private readonly OpenAIClient? _openAIClient; - /// The underlying . private readonly ChatClient _chatClient; - /// The use for any serialization activities related to tool call arguments and results. - private JsonSerializerOptions _toolCallJsonSerializerOptions = AIJsonUtilities.DefaultOptions; - - /// Initializes a new instance of the class for the specified . - /// The underlying client. - /// The model to use. - /// is . - /// is empty or composed entirely of whitespace. - public OpenAIChatClient(OpenAIClient openAIClient, string modelId) - { - _ = Throw.IfNull(openAIClient); - _ = Throw.IfNullOrWhitespace(modelId); - - _openAIClient = openAIClient; - _chatClient = openAIClient.GetChatClient(modelId); - - // https://github.com/openai/openai-dotnet/issues/215 - // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages - // implement the abstractions directly rather than providing adapters on top of the public APIs, - // the package can provide such implementations separate from what's exposed in the public API. - Uri providerUrl = typeof(OpenAIClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(openAIClient) as Uri ?? DefaultOpenAIEndpoint; - - _metadata = new ChatClientMetadata("openai", providerUrl, modelId); - } - /// Initializes a new instance of the class for the specified . /// The underlying client. /// is . @@ -80,13 +54,6 @@ public OpenAIChatClient(ChatClient chatClient) _metadata = new("openai", providerUrl, model); } - /// Gets or sets to use for any serialization activities related to tool call arguments and results. - public JsonSerializerOptions ToolCallJsonSerializerOptions - { - get => _toolCallJsonSerializerOptions; - set => _toolCallJsonSerializerOptions = Throw.IfNull(value); - } - /// object? IChatClient.GetService(Type serviceType, object? serviceKey) { @@ -95,7 +62,6 @@ public JsonSerializerOptions ToolCallJsonSerializerOptions return serviceKey is not null ? null : serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType == typeof(OpenAIClient) ? _openAIClient : serviceType == typeof(ChatClient) ? _chatClient : serviceType.IsInstanceOfType(this) ? this : null; @@ -107,13 +73,13 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = OpenAIModelMappers.ToOpenAIChatMessages(messages, ToolCallJsonSerializerOptions); - var openAIOptions = OpenAIModelMappers.ToOpenAIOptions(options); + var openAIChatMessages = ToOpenAIChatMessages(messages, AIJsonUtilities.DefaultOptions); + var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. var response = await _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken).ConfigureAwait(false); - return OpenAIModelMappers.FromOpenAIChatCompletion(response.Value, options, openAIOptions); + return FromOpenAIChatCompletion(response.Value, options, openAIOptions); } /// @@ -122,13 +88,13 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = OpenAIModelMappers.ToOpenAIChatMessages(messages, ToolCallJsonSerializerOptions); - var openAIOptions = OpenAIModelMappers.ToOpenAIOptions(options); + var openAIChatMessages = ToOpenAIChatMessages(messages, AIJsonUtilities.DefaultOptions); + var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. var chatCompletionUpdates = _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); - return OpenAIModelMappers.FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, cancellationToken); + return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, cancellationToken); } /// @@ -136,4 +102,656 @@ void IDisposable.Dispose() { // Nothing to dispose. Implementation required for the IChatClient interface. } + + private static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer"); + + /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. + private static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, JsonSerializerOptions options) + { + // Maps all of the M.E.AI types to the corresponding OpenAI types. + // Unrecognized or non-processable content is ignored. + + foreach (ChatMessage input in inputs) + { + if (input.Role == ChatRole.System || + input.Role == ChatRole.User || + input.Role == ChatRoleDeveloper) + { + var parts = ToOpenAIChatContent(input.Contents); + yield return + input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = input.AuthorName } : + input.Role == ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } : + new UserChatMessage(parts) { ParticipantName = input.AuthorName }; + } + else if (input.Role == ChatRole.Tool) + { + foreach (AIContent item in input.Contents) + { + if (item is FunctionResultContent resultContent) + { + string? result = resultContent.Result as string; + if (result is null && resultContent.Result is not null) + { + try + { + result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object))); + } + catch (NotSupportedException) + { + // If the type can't be serialized, skip it. + } + } + + yield return new ToolChatMessage(resultContent.CallId, result ?? string.Empty); + } + } + } + else if (input.Role == ChatRole.Assistant) + { + AssistantChatMessage message = new(ToOpenAIChatContent(input.Contents)) + { + ParticipantName = input.AuthorName + }; + + foreach (var content in input.Contents) + { + if (content is FunctionCallContent callRequest) + { + message.ToolCalls.Add( + ChatToolCall.CreateFunctionToolCall( + callRequest.CallId, + callRequest.Name, + new(JsonSerializer.SerializeToUtf8Bytes( + callRequest.Arguments, + options.GetTypeInfo(typeof(IDictionary)))))); + } + } + + if (input.AdditionalProperties?.TryGetValue(nameof(message.Refusal), out string? refusal) is true) + { + message.Refusal = refusal; + } + + yield return message; + } + } + } + + /// Converts a list of to a list of . + private static List ToOpenAIChatContent(IList contents) + { + List parts = []; + foreach (var content in contents) + { + switch (content) + { + case TextContent textContent: + parts.Add(ChatMessageContentPart.CreateTextPart(textContent.Text)); + break; + + case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): + parts.Add(ChatMessageContentPart.CreateImagePart(uriContent.Uri)); + break; + + case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): + parts.Add(ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType)); + break; + + case DataContent dataContent when dataContent.HasTopLevelMediaType("audio"): + var audioData = BinaryData.FromBytes(dataContent.Data); + if (dataContent.MediaType.Equals("audio/mpeg", StringComparison.OrdinalIgnoreCase)) + { + parts.Add(ChatMessageContentPart.CreateInputAudioPart(audioData, ChatInputAudioFormat.Mp3)); + } + else if (dataContent.MediaType.Equals("audio/wav", StringComparison.OrdinalIgnoreCase)) + { + parts.Add(ChatMessageContentPart.CreateInputAudioPart(audioData, ChatInputAudioFormat.Wav)); + } + + break; + } + } + + if (parts.Count == 0) + { + parts.Add(ChatMessageContentPart.CreateTextPart(string.Empty)); + } + + return parts; + } + + private static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( + IAsyncEnumerable updates, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + 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)) + { + // The role and finish reason may arrive during any update, but once they've arrived, the same value should be the same for all subsequent updates. + streamedRole ??= update.Role is ChatMessageRole role ? FromOpenAIChatRole(role) : null; + finishReason ??= update.FinishReason is OpenAI.Chat.ChatFinishReason reason ? FromOpenAIFinishReason(reason) : null; + responseId ??= update.CompletionId; + createdAt ??= update.CreatedAt; + modelId ??= update.Model; + fingerprint ??= update.SystemFingerprint; + + // Create the response content object. + ChatResponseUpdate responseUpdate = new() + { + ResponseId = update.CompletionId, + MessageId = update.CompletionId, // There is no per-message ID, but there's only one message per response, so use the response ID + CreatedAt = update.CreatedAt, + FinishReason = finishReason, + ModelId = modelId, + RawRepresentation = update, + 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 }) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + if (ToAIContent(contentPart) is AIContent aiContent) + { + responseUpdate.Contents.Add(aiContent); + } + } + } + + // 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) + { + foreach (StreamingChatToolCallUpdate toolCallUpdate in toolCallUpdates) + { + functionCallInfos ??= []; + if (!functionCallInfos.TryGetValue(toolCallUpdate.Index, out FunctionCallInfo? existing)) + { + functionCallInfos[toolCallUpdate.Index] = existing = new(); + } + + existing.CallId ??= toolCallUpdate.ToolCallId; + existing.Name ??= toolCallUpdate.FunctionName; + if (toolCallUpdate.FunctionArgumentsUpdate is { } argUpdate && !argUpdate.ToMemory().IsEmpty) + { + _ = (existing.Arguments ??= new()).Append(argUpdate.ToString()); + } + } + } + + // Transfer over usage updates. + if (update.Usage is ChatTokenUsage tokenUsage) + { + var usageDetails = FromOpenAIUsage(tokenUsage); + responseUpdate.Contents.Add(new UsageContent(usageDetails)); + } + + // Now yield the item. + yield return responseUpdate; + } + + // Now that we've received all updates, combine any for function calls into a single item to yield. + if (functionCallInfos is not null) + { + ChatResponseUpdate responseUpdate = new() + { + ResponseId = responseId, + MessageId = responseId, // There is no per-message ID, but there's only one message per response, so use the response ID + CreatedAt = createdAt, + FinishReason = finishReason, + ModelId = modelId, + Role = streamedRole, + }; + + foreach (var entry in functionCallInfos) + { + FunctionCallInfo fci = entry.Value; + if (!string.IsNullOrWhiteSpace(fci.Name)) + { + var callContent = ParseCallContentFromJsonString( + fci.Arguments?.ToString() ?? string.Empty, + fci.CallId!, + fci.Name!); + responseUpdate.Contents.Add(callContent); + } + } + + // 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; + } + } + + private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatOptions? options, ChatCompletionOptions chatCompletionOptions) + { + _ = Throw.IfNull(openAICompletion); + + // Create the return message. + ChatMessage returnMessage = new() + { + MessageId = openAICompletion.Id, // There's no per-message ID, so we use the same value as the response ID + RawRepresentation = openAICompletion, + Role = FromOpenAIChatRole(openAICompletion.Role), + }; + + // Populate its content from those in the OpenAI response content. + foreach (ChatMessageContentPart contentPart in openAICompletion.Content) + { + if (ToAIContent(contentPart) is AIContent aiContent) + { + returnMessage.Contents.Add(aiContent); + } + } + + // Output audio is handled separately from message content parts. + if (openAICompletion.OutputAudio is ChatOutputAudio audio) + { + string mimeType = chatCompletionOptions?.AudioOptions?.OutputAudioFormat.ToString()?.ToLowerInvariant() switch + { + "opus" => "audio/opus", + "aac" => "audio/aac", + "flac" => "audio/flac", + "wav" => "audio/wav", + "pcm" => "audio/pcm", + "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; + } + + returnMessage.Contents.Add(dc); + } + + // Also manufacture function calling content items from any tool calls in the response. + if (options?.Tools is { Count: > 0 }) + { + foreach (ChatToolCall toolCall in openAICompletion.ToolCalls) + { + if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) + { + var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + callContent.RawRepresentation = toolCall; + + returnMessage.Contents.Add(callContent); + } + } + } + + // Wrap the content in a ChatResponse to return. + var response = new ChatResponse(returnMessage) + { + CreatedAt = openAICompletion.CreatedAt, + FinishReason = FromOpenAIFinishReason(openAICompletion.FinishReason), + ModelId = openAICompletion.Model, + RawRepresentation = openAICompletion, + ResponseId = openAICompletion.Id, + }; + + if (openAICompletion.Usage is ChatTokenUsage tokenUsage) + { + response.Usage = FromOpenAIUsage(tokenUsage); + } + + if (openAICompletion.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) + { + (response.AdditionalProperties ??= [])[nameof(openAICompletion.ContentTokenLogProbabilities)] = contentTokenLogProbs; + } + + if (openAICompletion.Refusal is string refusal) + { + (response.AdditionalProperties ??= [])[nameof(openAICompletion.Refusal)] = refusal; + } + + 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) + { + 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; +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + result.Seed = options.Seed; +#pragma warning restore OPENAI001 + + if (options.StopSequences is { Count: > 0 } stopSequences) + { + foreach (string stopSequence in stopSequences) + { + result.StopSequences.Add(stopSequence); + } + } + + if (options.AdditionalProperties is { Count: > 0 } additionalProperties) + { + if (additionalProperties.TryGetValue(nameof(result.AllowParallelToolCalls), out bool allowParallelToolCalls)) + { + result.AllowParallelToolCalls = allowParallelToolCalls; + } + + 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)) + { + 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; + } + } + + if (options.Tools is { Count: > 0 } tools) + { + foreach (AITool tool in tools) + { + if (tool is AIFunction af) + { + result.Tools.Add(ToOpenAIChatTool(af)); + } + } + + 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; + } + } + + 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, + jsonSchemaIsStrict: true) : + OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); + } + } + + return result; + } + + /// Converts an Extensions function to an OpenAI chat tool. + private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) + { + // Default strict to true, but allow to be overridden by an additional Strict property. + bool strict = + !aiFunction.AdditionalProperties.TryGetValue("Strict", out object? strictObj) || + strictObj is not bool strictValue || + strictValue; + + // Map to an intermediate model so that redundant properties are skipped. + var tool = JsonSerializer.Deserialize(aiFunction.JsonSchema, ChatClientJsonContext.Default.ChatToolJson)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, ChatClientJsonContext.Default.ChatToolJson)); + return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); + } + + private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage) + { + var destination = new UsageDetails + { + InputTokenCount = tokenUsage.InputTokenCount, + OutputTokenCount = tokenUsage.OutputTokenCount, + TotalTokenCount = tokenUsage.TotalTokenCount, + AdditionalCounts = [], + }; + + var counts = destination.AdditionalCounts; + + if (tokenUsage.InputTokenDetails is ChatInputTokenUsageDetails inputDetails) + { + const string InputDetails = nameof(ChatTokenUsage.InputTokenDetails); + counts.Add($"{InputDetails}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", inputDetails.AudioTokenCount); + counts.Add($"{InputDetails}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); + } + + if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails outputDetails) + { + const string OutputDetails = nameof(ChatTokenUsage.OutputTokenDetails); + counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); + counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", outputDetails.AudioTokenCount); + counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.AcceptedPredictionTokenCount)}", outputDetails.AcceptedPredictionTokenCount); + counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.RejectedPredictionTokenCount)}", outputDetails.RejectedPredictionTokenCount); + } + + return destination; + } + + /// Converts an OpenAI role to an Extensions role. + private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => + role switch + { + ChatMessageRole.System => ChatRole.System, + ChatMessageRole.User => ChatRole.User, + ChatMessageRole.Assistant => ChatRole.Assistant, + ChatMessageRole.Tool => ChatRole.Tool, + ChatMessageRole.Developer => ChatRoleDeveloper, + _ => new ChatRole(role.ToString()), + }; + + /// Creates an from a . + /// The content part to convert into a content. + /// The constructed , or if the content part could not be converted. + private static AIContent? ToAIContent(ChatMessageContentPart contentPart) + { + AIContent? aiContent = null; + + if (contentPart.Kind == ChatMessageContentPartKind.Text) + { + aiContent = new TextContent(contentPart.Text); + } + else if (contentPart.Kind == ChatMessageContentPartKind.Image) + { + aiContent = + contentPart.ImageUri is not null ? new UriContent(contentPart.ImageUri, "image/*") : + contentPart.ImageBytes is not null ? new DataContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : + null; + + if (aiContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) + { + (aiContent.AdditionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; + } + } + + if (aiContent is not null) + { + if (contentPart.Refusal is string refusal) + { + (aiContent.AdditionalProperties ??= [])[nameof(contentPart.Refusal)] = refusal; + } + + aiContent.RawRepresentation = contentPart; + } + + return aiContent; + } + + /// Converts an OpenAI finish reason to an Extensions finish reason. + private static ChatFinishReason? FromOpenAIFinishReason(OpenAI.Chat.ChatFinishReason? finishReason) => + finishReason?.ToString() is not string s ? null : + finishReason switch + { + OpenAI.Chat.ChatFinishReason.Stop => ChatFinishReason.Stop, + OpenAI.Chat.ChatFinishReason.Length => ChatFinishReason.Length, + OpenAI.Chat.ChatFinishReason.ContentFilter => ChatFinishReason.ContentFilter, + OpenAI.Chat.ChatFinishReason.ToolCalls or OpenAI.Chat.ChatFinishReason.FunctionCall => ChatFinishReason.ToolCalls, + _ => new ChatFinishReason(s), + }; + + private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(json, callId, name, + argumentParser: static json => JsonSerializer.Deserialize(json, ChatClientJsonContext.Default.IDictionaryStringObject)!); + + private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, + argumentParser: static json => JsonSerializer.Deserialize(json, ChatClientJsonContext.Default.IDictionaryStringObject)!); + + /// 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; } + } + + /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. + private sealed class FunctionCallInfo + { + public string? CallId; + public string? Name; + public StringBuilder? Arguments; + } + + /// Source-generated JSON type information. + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] + [JsonSerializable(typeof(ChatToolJson))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(string[]))] + private sealed partial class ChatClientJsonContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatCompletionRequest.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatCompletionRequest.cs deleted file mode 100644 index 6a28c9f5490..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatCompletionRequest.cs +++ /dev/null @@ -1,69 +0,0 @@ -// 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.ComponentModel; -using System.Text.Json; -using System.Text.Json.Serialization; -using OpenAI.Chat; - -#pragma warning disable CA1034 // Nested types should not be visible - -namespace Microsoft.Extensions.AI; - -/// -/// Represents an OpenAI chat completion request deserialized as Microsoft.Extension.AI models. -/// -[JsonConverter(typeof(Converter))] -public sealed class OpenAIChatCompletionRequest -{ - /// - /// Gets the chat messages specified in the request. - /// - public required IList Messages { get; init; } - - /// - /// Gets the chat options governing the request. - /// - public required ChatOptions Options { get; init; } - - /// - /// Gets a value indicating whether the response should be streamed. - /// - public bool Stream { get; init; } - - /// - /// Gets the model id requested by the chat completion. - /// - public string? ModelId { get; init; } - - /// - /// Converts an OpenAIChatCompletionRequest object to and from JSON. - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public sealed class Converter : JsonConverter - { - /// - /// Reads and converts the JSON to type OpenAIChatCompletionRequest. - /// - /// The reader. - /// The type to convert. - /// The serializer options. - /// The converted OpenAIChatCompletionRequest object. - public override OpenAIChatCompletionRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - ChatCompletionOptions chatCompletionOptions = JsonModelHelpers.Deserialize(ref reader); - return OpenAIModelMappers.FromOpenAIChatCompletionRequest(chatCompletionOptions); - } - - /// - /// Writes the specified value as JSON. - /// - /// The writer. - /// The value to write. - /// The serializer options. - public override void Write(Utf8JsonWriter writer, OpenAIChatCompletionRequest value, JsonSerializerOptions options) => - throw new NotSupportedException("Request body serialization is not supported."); - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 483786a3174..6b330e4da00 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -1,8 +1,10 @@ // 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.ComponentModel; +using Microsoft.Shared.Diagnostics; using OpenAI; -using OpenAI.Assistants; using OpenAI.Chat; using OpenAI.Embeddings; using OpenAI.Responses; @@ -16,46 +18,37 @@ public static class OpenAIClientExtensions /// The client. /// The model. /// An that can be used to converse via the . + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("This method will be removed in an upcoming release.")] public static IChatClient AsChatClient(this OpenAIClient openAIClient, string modelId) => - new OpenAIChatClient(openAIClient, modelId); + new OpenAIChatClient(Throw.IfNull(openAIClient).GetChatClient(modelId)); /// Gets an for use with this . /// The client. /// An that can be used to converse via the . - public static IChatClient AsChatClient(this ChatClient chatClient) => + public static IChatClient AsIChatClient(this ChatClient chatClient) => new OpenAIChatClient(chatClient); /// Gets an for use with this . /// The client. /// An that can be used to converse via the . - public static IChatClient AsChatClient(this OpenAIResponseClient responseClient) => + public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient) => new OpenAIResponseChatClient(responseClient); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only - /// Gets an for use with this . - /// The client. - /// The ID of the assistant to use. - /// - /// The ID of the thread to use. If not supplied here, it should be supplied per request in . - /// If none is supplied, a new thread will be created for a request. - /// - /// An that can be used to converse via the . - public static IChatClient AsChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) => - new OpenAIAssistantClient(assistantClient, assistantId, threadId); -#pragma warning restore OPENAI001 - /// Gets an for use with this . /// The client. /// The model to use. /// The number of dimensions to generate in each embedding. /// An that can be used to generate embeddings via the . + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("This method will be removed in an upcoming release.")] public static IEmbeddingGenerator> AsEmbeddingGenerator(this OpenAIClient openAIClient, string modelId, int? dimensions = null) => - new OpenAIEmbeddingGenerator(openAIClient, modelId, dimensions); + new OpenAIEmbeddingGenerator(Throw.IfNull(openAIClient).GetEmbeddingClient(modelId), dimensions); /// Gets an for use with this . /// The client. - /// The number of dimensions to generate in each embedding. + /// The number of dimensions to generate in each embedding. /// An that can be used to generate embeddings via the . - public static IEmbeddingGenerator> AsEmbeddingGenerator(this EmbeddingClient embeddingClient, int? dimensions = null) => - new OpenAIEmbeddingGenerator(embeddingClient, dimensions); + public static IEmbeddingGenerator> AsIEmbeddingGenerator(this EmbeddingClient embeddingClient, int? defaultModelDimensions = null) => + new OpenAIEmbeddingGenerator(embeddingClient, defaultModelDimensions); } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index 8ae8a32b898..cf54b7906f0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -17,7 +17,7 @@ namespace Microsoft.Extensions.AI; /// An for an OpenAI . -public sealed class OpenAIEmbeddingGenerator : IEmbeddingGenerator> +internal sealed class OpenAIEmbeddingGenerator : IEmbeddingGenerator> { /// Default OpenAI endpoint. private const string DefaultOpenAIEndpoint = "https://api.openai.com/v1"; @@ -25,62 +25,27 @@ public sealed class OpenAIEmbeddingGenerator : IEmbeddingGeneratorMetadata about the embedding generator. private readonly EmbeddingGeneratorMetadata _metadata; - /// The underlying . - private readonly OpenAIClient? _openAIClient; - /// The underlying . private readonly EmbeddingClient _embeddingClient; /// The number of dimensions produced by the generator. private readonly int? _dimensions; - /// Initializes a new instance of the class. - /// The underlying client. - /// The model to use. - /// The number of dimensions to generate in each embedding. - /// is . - /// is empty or composed entirely of whitespace. - /// is not positive. - public OpenAIEmbeddingGenerator( - OpenAIClient openAIClient, string modelId, int? dimensions = null) - { - _ = Throw.IfNull(openAIClient); - _ = Throw.IfNullOrWhitespace(modelId); - if (dimensions is < 1) - { - Throw.ArgumentOutOfRangeException(nameof(dimensions), "Value must be greater than 0."); - } - - _openAIClient = openAIClient; - _embeddingClient = openAIClient.GetEmbeddingClient(modelId); - _dimensions = dimensions; - - // https://github.com/openai/openai-dotnet/issues/215 - // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages - // implement the abstractions directly rather than providing adapters on top of the public APIs, - // the package can provide such implementations separate from what's exposed in the public API. - string providerUrl = (typeof(OpenAIClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(openAIClient) as Uri)?.ToString() ?? - DefaultOpenAIEndpoint; - - _metadata = CreateMetadata("openai", providerUrl, modelId, dimensions); - } - /// Initializes a new instance of the class. /// The underlying client. - /// The number of dimensions to generate in each embedding. + /// The number of dimensions to generate in each embedding. /// is . - /// is not positive. - public OpenAIEmbeddingGenerator(EmbeddingClient embeddingClient, int? dimensions = null) + /// is not positive. + public OpenAIEmbeddingGenerator(EmbeddingClient embeddingClient, int? defaultModelDimensions = null) { _ = Throw.IfNull(embeddingClient); - if (dimensions < 1) + if (defaultModelDimensions < 1) { - Throw.ArgumentOutOfRangeException(nameof(dimensions), "Value must be greater than 0."); + Throw.ArgumentOutOfRangeException(nameof(defaultModelDimensions), "Value must be greater than 0."); } _embeddingClient = embeddingClient; - _dimensions = dimensions; + _dimensions = defaultModelDimensions; // https://github.com/openai/openai-dotnet/issues/215 // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages @@ -91,9 +56,9 @@ public OpenAIEmbeddingGenerator(EmbeddingClient embeddingClient, int? dimensions DefaultOpenAIEndpoint; FieldInfo? modelField = typeof(EmbeddingClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - string? model = modelField?.GetValue(embeddingClient) as string; + string? modelId = modelField?.GetValue(embeddingClient) as string; - _metadata = CreateMetadata("openai", providerUrl, model, dimensions); + _metadata = CreateMetadata("openai", providerUrl, modelId, defaultModelDimensions); } /// @@ -132,15 +97,14 @@ void IDisposable.Dispose() return serviceKey is not null ? null : serviceType == typeof(EmbeddingGeneratorMetadata) ? _metadata : - serviceType == typeof(OpenAIClient) ? _openAIClient : serviceType == typeof(EmbeddingClient) ? _embeddingClient : serviceType.IsInstanceOfType(this) ? this : null; } /// Creates the for this instance. - private static EmbeddingGeneratorMetadata CreateMetadata(string providerName, string providerUrl, string? model, int? dimensions) => - new(providerName, Uri.TryCreate(providerUrl, UriKind.Absolute, out Uri? providerUri) ? providerUri : null, model, dimensions); + private static EmbeddingGeneratorMetadata CreateMetadata(string providerName, string providerUrl, string? defaultModelId, int? defaultModelDimensions) => + new(providerName, Uri.TryCreate(providerUrl, UriKind.Absolute, out Uri? providerUri) ? providerUri : null, defaultModelId, defaultModelDimensions); /// Converts an extensions options instance to an OpenAI options instance. private OpenAI.Embeddings.EmbeddingGenerationOptions? ToOpenAIOptions(EmbeddingGenerationOptions? options) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs deleted file mode 100644 index c75b2a4c644..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -/// Source-generated JSON type information. -[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true)] -[JsonSerializable(typeof(OpenAIRealtimeExtensions.ConversationFunctionToolParametersSchema))] -[JsonSerializable(typeof(OpenAIModelMappers.OpenAIChatToolJson))] -[JsonSerializable(typeof(IDictionary))] -[JsonSerializable(typeof(string[]))] -internal sealed partial class OpenAIJsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs deleted file mode 100644 index ea28823e8fb..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs +++ /dev/null @@ -1,650 +0,0 @@ -// 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.Diagnostics.CodeAnalysis; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; -using OpenAI.Chat; - -#pragma warning disable CA1308 // Normalize strings to uppercase -#pragma warning disable CA1859 // Use concrete types when possible for improved performance -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S103 // Lines should not be too long -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S2178 // Short-circuit logic should be used in boolean contexts -#pragma warning disable S3440 // Variables should not be checked against the values they're about to be assigned -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) - -namespace Microsoft.Extensions.AI; - -internal static partial class OpenAIModelMappers -{ - public static ChatCompletion ToOpenAIChatCompletion(ChatResponse response, JsonSerializerOptions options) - { - _ = Throw.IfNull(response); - - List? toolCalls = null; - ChatRole? role = null; - List allContents = []; - foreach (ChatMessage message in response.Messages) - { - role = message.Role; - foreach (AIContent content in message.Contents) - { - allContents.Add(content); - if (content is FunctionCallContent callRequest) - { - toolCalls ??= []; - toolCalls.Add(ChatToolCall.CreateFunctionToolCall( - callRequest.CallId, - callRequest.Name, - new(JsonSerializer.SerializeToUtf8Bytes( - callRequest.Arguments, - options.GetTypeInfo(typeof(IDictionary)))))); - } - } - } - - ChatTokenUsage? chatTokenUsage = null; - if (response.Usage is UsageDetails usageDetails) - { - chatTokenUsage = ToOpenAIUsage(usageDetails); - } - - return OpenAIChatModelFactory.ChatCompletion( - id: response.ResponseId ?? CreateCompletionId(), - model: response.ModelId, - createdAt: response.CreatedAt ?? DateTimeOffset.UtcNow, - role: ToOpenAIChatRole(role) ?? ChatMessageRole.Assistant, - finishReason: ToOpenAIFinishReason(response.FinishReason), - content: new(ToOpenAIChatContent(allContents)), - toolCalls: toolCalls, - refusal: response.AdditionalProperties.GetValueOrDefault(nameof(ChatCompletion.Refusal)), - contentTokenLogProbabilities: response.AdditionalProperties.GetValueOrDefault>(nameof(ChatCompletion.ContentTokenLogProbabilities)), - refusalTokenLogProbabilities: response.AdditionalProperties.GetValueOrDefault>(nameof(ChatCompletion.RefusalTokenLogProbabilities)), - systemFingerprint: response.AdditionalProperties.GetValueOrDefault(nameof(ChatCompletion.SystemFingerprint)), - usage: chatTokenUsage); - } - - public static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatOptions? options, ChatCompletionOptions chatCompletionOptions) - { - _ = Throw.IfNull(openAICompletion); - - // Create the return message. - ChatMessage returnMessage = new() - { - MessageId = openAICompletion.Id, // There's no per-message ID, so we use the same value as the response ID - RawRepresentation = openAICompletion, - Role = FromOpenAIChatRole(openAICompletion.Role), - }; - - // Populate its content from those in the OpenAI response content. - foreach (ChatMessageContentPart contentPart in openAICompletion.Content) - { - if (ToAIContent(contentPart) is AIContent aiContent) - { - returnMessage.Contents.Add(aiContent); - } - } - - // Output audio is handled separately from message content parts. - if (openAICompletion.OutputAudio is ChatOutputAudio audio) - { - string mimeType = chatCompletionOptions?.AudioOptions?.OutputAudioFormat.ToString()?.ToLowerInvariant() switch - { - "opus" => "audio/opus", - "aac" => "audio/aac", - "flac" => "audio/flac", - "wav" => "audio/wav", - "pcm" => "audio/pcm", - "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; - } - - returnMessage.Contents.Add(dc); - } - - // Also manufacture function calling content items from any tool calls in the response. - if (options?.Tools is { Count: > 0 }) - { - foreach (ChatToolCall toolCall in openAICompletion.ToolCalls) - { - if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) - { - var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); - callContent.RawRepresentation = toolCall; - - returnMessage.Contents.Add(callContent); - } - } - } - - // Wrap the content in a ChatResponse to return. - var response = new ChatResponse(returnMessage) - { - CreatedAt = openAICompletion.CreatedAt, - FinishReason = FromOpenAIFinishReason(openAICompletion.FinishReason), - ModelId = openAICompletion.Model, - RawRepresentation = openAICompletion, - ResponseId = openAICompletion.Id, - }; - - if (openAICompletion.Usage is ChatTokenUsage tokenUsage) - { - response.Usage = FromOpenAIUsage(tokenUsage); - } - - if (openAICompletion.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) - { - (response.AdditionalProperties ??= [])[nameof(openAICompletion.ContentTokenLogProbabilities)] = contentTokenLogProbs; - } - - if (openAICompletion.Refusal is string refusal) - { - (response.AdditionalProperties ??= [])[nameof(openAICompletion.Refusal)] = refusal; - } - - 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; - } - - public static ChatOptions FromOpenAIOptions(ChatCompletionOptions? options) - { - ChatOptions result = new(); - - if (options is not null) - { - result.ModelId = _getModelIdAccessor.Invoke(options, null)?.ToString() switch - { - null or "" => null, - var modelId => modelId, - }; - - result.FrequencyPenalty = options.FrequencyPenalty; - result.MaxOutputTokens = options.MaxOutputTokenCount; - result.TopP = options.TopP; - result.PresencePenalty = options.PresencePenalty; - result.Temperature = options.Temperature; -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - result.Seed = options.Seed; -#pragma warning restore OPENAI001 - - if (options.StopSequences is { Count: > 0 } stopSequences) - { - result.StopSequences = [.. stopSequences]; - } - - if (options.EndUserId is string endUserId) - { - (result.AdditionalProperties ??= [])[nameof(options.EndUserId)] = endUserId; - } - - if (options.IncludeLogProbabilities is bool includeLogProbabilities) - { - (result.AdditionalProperties ??= [])[nameof(options.IncludeLogProbabilities)] = includeLogProbabilities; - } - - if (options.LogitBiases is { Count: > 0 } logitBiases) - { - (result.AdditionalProperties ??= [])[nameof(options.LogitBiases)] = new Dictionary(logitBiases); - } - - if (options.AllowParallelToolCalls is bool allowParallelToolCalls) - { - (result.AdditionalProperties ??= [])[nameof(options.AllowParallelToolCalls)] = allowParallelToolCalls; - } - - if (options.TopLogProbabilityCount is int topLogProbabilityCount) - { - (result.AdditionalProperties ??= [])[nameof(options.TopLogProbabilityCount)] = topLogProbabilityCount; - } - - if (options.Metadata is IDictionary { Count: > 0 } metadata) - { - (result.AdditionalProperties ??= [])[nameof(options.Metadata)] = new Dictionary(metadata); - } - - if (options.StoredOutputEnabled is bool storedOutputEnabled) - { - (result.AdditionalProperties ??= [])[nameof(options.StoredOutputEnabled)] = storedOutputEnabled; - } - - if (options.Tools is { Count: > 0 } tools) - { - foreach (ChatTool tool in tools) - { - if (FromOpenAIChatTool(tool) is { } convertedTool) - { - (result.Tools ??= []).Add(convertedTool); - } - } - - using var toolChoiceJson = JsonDocument.Parse(JsonModelHelpers.Serialize(options.ToolChoice).ToMemory()); - JsonElement jsonElement = toolChoiceJson.RootElement; - switch (jsonElement.ValueKind) - { - case JsonValueKind.String: - result.ToolMode = jsonElement.GetString() switch - { - "required" => ChatToolMode.RequireAny, - "none" => ChatToolMode.None, - _ => ChatToolMode.Auto, - }; - - break; - case JsonValueKind.Object: - if (jsonElement.TryGetProperty("function", out JsonElement functionElement)) - { - result.ToolMode = ChatToolMode.RequireSpecific(functionElement.GetString()!); - } - - break; - } - } - } - - return result; - } - - /// Converts an extensions options instance to an OpenAI options instance. - public 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; -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. - result.Seed = options.Seed; -#pragma warning restore OPENAI001 - - if (options.StopSequences is { Count: > 0 } stopSequences) - { - foreach (string stopSequence in stopSequences) - { - result.StopSequences.Add(stopSequence); - } - } - - if (options.AdditionalProperties is { Count: > 0 } additionalProperties) - { - if (additionalProperties.TryGetValue(nameof(result.AllowParallelToolCalls), out bool allowParallelToolCalls)) - { - result.AllowParallelToolCalls = allowParallelToolCalls; - } - - 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)) - { - 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; - } - } - - if (options.Tools is { Count: > 0 } tools) - { - foreach (AITool tool in tools) - { - if (tool is AIFunction af) - { - result.Tools.Add(ToOpenAIChatTool(af)); - } - } - - 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; - } - } - - 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, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription, - jsonSchemaIsStrict: true) : - OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); - } - } - - return result; - } - - private static AITool? FromOpenAIChatTool(ChatTool chatTool) - { - switch (chatTool.Kind) - { - case ChatToolKind.Function: - AdditionalPropertiesDictionary additionalProperties = []; - if (chatTool.FunctionSchemaIsStrict is bool strictValue) - { - additionalProperties["Strict"] = strictValue; - } - - OpenAIChatToolJson openAiChatTool = JsonSerializer.Deserialize(chatTool.FunctionParameters.ToMemory().Span, OpenAIJsonContext.Default.OpenAIChatToolJson)!; - JsonElement schema = JsonSerializer.SerializeToElement(openAiChatTool, OpenAIJsonContext.Default.OpenAIChatToolJson); - return new MetadataOnlyAIFunction(chatTool.FunctionName, chatTool.FunctionDescription, schema, additionalProperties); - - default: - return null; - } - } - - private sealed class MetadataOnlyAIFunction(string name, string description, JsonElement schema, IReadOnlyDictionary additionalProps) : AIFunction - { - public override string Name => name; - public override string Description => description; - public override JsonElement JsonSchema => schema; - public override IReadOnlyDictionary AdditionalProperties => additionalProps; - protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => - throw new InvalidOperationException($"The AI function '{Name}' does not support being invoked."); - } - - /// Converts an Extensions function to an OpenAI chat tool. - private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) - { - // Default strict to true, but allow to be overridden by an additional Strict property. - bool strict = - !aiFunction.AdditionalProperties.TryGetValue("Strict", out object? strictObj) || - strictObj is not bool strictValue || - strictValue; - - // Map to an intermediate model so that redundant properties are skipped. - var tool = JsonSerializer.Deserialize(aiFunction.JsonSchema, OpenAIJsonContext.Default.OpenAIChatToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.OpenAIChatToolJson)); - return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); - } - - private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage) - { - var destination = new UsageDetails - { - InputTokenCount = tokenUsage.InputTokenCount, - OutputTokenCount = tokenUsage.OutputTokenCount, - TotalTokenCount = tokenUsage.TotalTokenCount, - AdditionalCounts = [], - }; - - var counts = destination.AdditionalCounts; - - if (tokenUsage.InputTokenDetails is ChatInputTokenUsageDetails inputDetails) - { - const string InputDetails = nameof(ChatTokenUsage.InputTokenDetails); - counts.Add($"{InputDetails}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", inputDetails.AudioTokenCount); - counts.Add($"{InputDetails}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); - } - - if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails outputDetails) - { - const string OutputDetails = nameof(ChatTokenUsage.OutputTokenDetails); - counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); - counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", outputDetails.AudioTokenCount); - counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.AcceptedPredictionTokenCount)}", outputDetails.AcceptedPredictionTokenCount); - counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.RejectedPredictionTokenCount)}", outputDetails.RejectedPredictionTokenCount); - } - - return destination; - } - - private static ChatTokenUsage ToOpenAIUsage(UsageDetails usageDetails) - { - ChatOutputTokenUsageDetails? outputTokenUsageDetails = null; - ChatInputTokenUsageDetails? inputTokenUsageDetails = null; - - if (usageDetails.AdditionalCounts is { Count: > 0 } additionalCounts) - { - const string InputDetails = nameof(ChatTokenUsage.InputTokenDetails); - if (additionalCounts.TryGetValue($"{InputDetails}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", out int inputAudioTokenCount) | - additionalCounts.TryGetValue($"{InputDetails}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", out int inputCachedTokenCount)) - { - inputTokenUsageDetails = OpenAIChatModelFactory.ChatInputTokenUsageDetails( - audioTokenCount: inputAudioTokenCount, - cachedTokenCount: inputCachedTokenCount); - } - - const string OutputDetails = nameof(ChatTokenUsage.OutputTokenDetails); - if (additionalCounts.TryGetValue($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)}", out int outputReasoningTokenCount) | - additionalCounts.TryGetValue($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", out int outputAudioTokenCount) | - additionalCounts.TryGetValue($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.AcceptedPredictionTokenCount)}", out int outputAcceptedPredictionCount) | - additionalCounts.TryGetValue($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.RejectedPredictionTokenCount)}", out int outputRejectedPredictionCount)) - { - outputTokenUsageDetails = OpenAIChatModelFactory.ChatOutputTokenUsageDetails( - reasoningTokenCount: outputReasoningTokenCount, - audioTokenCount: outputAudioTokenCount, - acceptedPredictionTokenCount: outputAcceptedPredictionCount, - rejectedPredictionTokenCount: outputRejectedPredictionCount); - } - } - - return OpenAIChatModelFactory.ChatTokenUsage( - inputTokenCount: ToInt32Saturate(usageDetails.InputTokenCount), - outputTokenCount: ToInt32Saturate(usageDetails.OutputTokenCount), - totalTokenCount: ToInt32Saturate(usageDetails.TotalTokenCount), - outputTokenDetails: outputTokenUsageDetails, - inputTokenDetails: inputTokenUsageDetails); - - static int ToInt32Saturate(long? value) => - value is null ? 0 : - value > int.MaxValue ? int.MaxValue : - value < int.MinValue ? int.MinValue : - (int)value; - } - - /// Converts an OpenAI role to an Extensions role. - private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => - role switch - { - ChatMessageRole.System => ChatRole.System, - ChatMessageRole.User => ChatRole.User, - ChatMessageRole.Assistant => ChatRole.Assistant, - ChatMessageRole.Tool => ChatRole.Tool, - ChatMessageRole.Developer => ChatRoleDeveloper, - _ => new ChatRole(role.ToString()), - }; - - /// Converts an Extensions role to an OpenAI role. - [return: NotNullIfNotNull("role")] - private static ChatMessageRole? ToOpenAIChatRole(ChatRole? role) => - role is null ? null : - role == ChatRole.System ? ChatMessageRole.System : - role == ChatRole.User ? ChatMessageRole.User : - role == ChatRole.Assistant ? ChatMessageRole.Assistant : - role == ChatRole.Tool ? ChatMessageRole.Tool : - role == OpenAIModelMappers.ChatRoleDeveloper ? ChatMessageRole.Developer : - ChatMessageRole.User; - - /// Creates an from a . - /// The content part to convert into a content. - /// The constructed , or null if the content part could not be converted. - private static AIContent? ToAIContent(ChatMessageContentPart contentPart) - { - AIContent? aiContent = null; - - if (contentPart.Kind == ChatMessageContentPartKind.Text) - { - aiContent = new TextContent(contentPart.Text); - } - else if (contentPart.Kind == ChatMessageContentPartKind.Image) - { - aiContent = - contentPart.ImageUri is not null ? new UriContent(contentPart.ImageUri, "image/*") : - contentPart.ImageBytes is not null ? new DataContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : - null; - - if (aiContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) - { - (aiContent.AdditionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; - } - } - - if (aiContent is not null) - { - if (contentPart.Refusal is string refusal) - { - (aiContent.AdditionalProperties ??= [])[nameof(contentPart.Refusal)] = refusal; - } - - aiContent.RawRepresentation = contentPart; - } - - return aiContent; - } - - /// Converts an OpenAI finish reason to an Extensions finish reason. - private static ChatFinishReason? FromOpenAIFinishReason(OpenAI.Chat.ChatFinishReason? finishReason) => - finishReason?.ToString() is not string s ? null : - finishReason switch - { - OpenAI.Chat.ChatFinishReason.Stop => ChatFinishReason.Stop, - OpenAI.Chat.ChatFinishReason.Length => ChatFinishReason.Length, - OpenAI.Chat.ChatFinishReason.ContentFilter => ChatFinishReason.ContentFilter, - OpenAI.Chat.ChatFinishReason.ToolCalls or OpenAI.Chat.ChatFinishReason.FunctionCall => ChatFinishReason.ToolCalls, - _ => new ChatFinishReason(s), - }; - - /// Converts an Extensions finish reason to an OpenAI finish reason. - private static OpenAI.Chat.ChatFinishReason ToOpenAIFinishReason(ChatFinishReason? finishReason) => - finishReason == ChatFinishReason.Length ? OpenAI.Chat.ChatFinishReason.Length : - finishReason == ChatFinishReason.ContentFilter ? OpenAI.Chat.ChatFinishReason.ContentFilter : - finishReason == ChatFinishReason.ToolCalls ? OpenAI.Chat.ChatFinishReason.ToolCalls : - OpenAI.Chat.ChatFinishReason.Stop; - - private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); - - private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); - - private static T? GetValueOrDefault(this AdditionalPropertiesDictionary? dict, string key) => - dict?.TryGetValue(key, out T? value) is true ? value : default; - - private static string CreateCompletionId() => $"chatcmpl-{Guid.NewGuid():N}"; - - /// Used to create the JSON payload for an OpenAI chat tool description. - public sealed class OpenAIChatToolJson - { - [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; } - } - - /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. - private sealed class FunctionCallInfo - { - public string? CallId; - public string? Name; - public StringBuilder? Arguments; - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs deleted file mode 100644 index 8d9195b0953..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs +++ /dev/null @@ -1,277 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#pragma warning disable SA1204 // Static elements should appear before instance elements - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text.Json; -using OpenAI.Chat; - -namespace Microsoft.Extensions.AI; - -internal static partial class OpenAIModelMappers -{ - public static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer"); - - public static OpenAIChatCompletionRequest FromOpenAIChatCompletionRequest(OpenAI.Chat.ChatCompletionOptions chatCompletionOptions) - { - ChatOptions chatOptions = FromOpenAIOptions(chatCompletionOptions); - IList messages = FromOpenAIChatMessages(_getMessagesAccessor(chatCompletionOptions)).ToList(); - return new() - { - Messages = messages, - ModelId = chatOptions.ModelId, - Options = chatOptions, - Stream = _getStreamAccessor(chatCompletionOptions) ?? false, - }; - } - - public static IEnumerable FromOpenAIChatMessages(IEnumerable inputs) - { - // Maps all of the OpenAI types to the corresponding M.E.AI types. - // Unrecognized or non-processable content is ignored. - - foreach (OpenAI.Chat.ChatMessage input in inputs) - { - switch (input) - { - case SystemChatMessage systemMessage: - yield return new ChatMessage - { - Role = ChatRole.System, - AuthorName = systemMessage.ParticipantName, - Contents = FromOpenAIChatContent(systemMessage.Content), - }; - break; - - case DeveloperChatMessage developerMessage: - yield return new ChatMessage - { - Role = ChatRoleDeveloper, - AuthorName = developerMessage.ParticipantName, - Contents = FromOpenAIChatContent(developerMessage.Content), - }; - break; - - case UserChatMessage userMessage: - yield return new ChatMessage - { - Role = ChatRole.User, - AuthorName = userMessage.ParticipantName, - Contents = FromOpenAIChatContent(userMessage.Content), - }; - break; - - case ToolChatMessage toolMessage: - string textContent = string.Join(string.Empty, toolMessage.Content.Where(part => part.Kind is ChatMessageContentPartKind.Text).Select(part => part.Text)); - object? result = textContent; - if (!string.IsNullOrEmpty(textContent)) - { -#pragma warning disable CA1031 // Do not catch general exception types - try - { - result = JsonSerializer.Deserialize(textContent, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); - } - catch - { - // If the content can't be deserialized, leave it as a string. - } -#pragma warning restore CA1031 // Do not catch general exception types - } - - yield return new ChatMessage - { - Role = ChatRole.Tool, - Contents = [new FunctionResultContent(toolMessage.ToolCallId, result)], - }; - break; - - case AssistantChatMessage assistantMessage: - - ChatMessage message = new() - { - Role = ChatRole.Assistant, - AuthorName = assistantMessage.ParticipantName, - Contents = FromOpenAIChatContent(assistantMessage.Content), - }; - - foreach (ChatToolCall toolCall in assistantMessage.ToolCalls) - { - if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) - { - var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); - callContent.RawRepresentation = toolCall; - - message.Contents.Add(callContent); - } - } - - if (assistantMessage.Refusal is not null) - { - message.AdditionalProperties ??= []; - message.AdditionalProperties.Add(nameof(assistantMessage.Refusal), assistantMessage.Refusal); - } - - yield return message; - break; - } - } - } - - /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. - public static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, JsonSerializerOptions options) - { - // Maps all of the M.E.AI types to the corresponding OpenAI types. - // Unrecognized or non-processable content is ignored. - - foreach (ChatMessage input in inputs) - { - if (input.Role == ChatRole.System || - input.Role == ChatRole.User || - input.Role == ChatRoleDeveloper) - { - var parts = ToOpenAIChatContent(input.Contents); - yield return - input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = input.AuthorName } : - input.Role == OpenAIModelMappers.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } : - new UserChatMessage(parts) { ParticipantName = input.AuthorName }; - } - else if (input.Role == ChatRole.Tool) - { - foreach (AIContent item in input.Contents) - { - if (item is FunctionResultContent resultContent) - { - string? result = resultContent.Result as string; - if (result is null && resultContent.Result is not null) - { - try - { - result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object))); - } - catch (NotSupportedException) - { - // If the type can't be serialized, skip it. - } - } - - yield return new ToolChatMessage(resultContent.CallId, result ?? string.Empty); - } - } - } - else if (input.Role == ChatRole.Assistant) - { - AssistantChatMessage message = new(ToOpenAIChatContent(input.Contents)) - { - ParticipantName = input.AuthorName - }; - - foreach (var content in input.Contents) - { - if (content is FunctionCallContent callRequest) - { - message.ToolCalls.Add( - ChatToolCall.CreateFunctionToolCall( - callRequest.CallId, - callRequest.Name, - new(JsonSerializer.SerializeToUtf8Bytes( - callRequest.Arguments, - options.GetTypeInfo(typeof(IDictionary)))))); - } - } - - if (input.AdditionalProperties?.TryGetValue(nameof(message.Refusal), out string? refusal) is true) - { - message.Refusal = refusal; - } - - yield return message; - } - } - } - - private static List FromOpenAIChatContent(IList openAiMessageContentParts) - { - List contents = []; - foreach (var openAiContentPart in openAiMessageContentParts) - { - switch (openAiContentPart.Kind) - { - case ChatMessageContentPartKind.Text: - contents.Add(new TextContent(openAiContentPart.Text)); - break; - - case ChatMessageContentPartKind.Image when openAiContentPart.ImageBytes is { } bytes: - contents.Add(new DataContent(bytes.ToMemory(), openAiContentPart.ImageBytesMediaType)); - break; - - case ChatMessageContentPartKind.Image when openAiContentPart.ImageUri is { } uri: - contents.Add(new UriContent(uri, "image/*")); - break; - } - } - - return contents; - } - - /// Converts a list of to a list of . - private static List ToOpenAIChatContent(IList contents) - { - List parts = []; - foreach (var content in contents) - { - switch (content) - { - case TextContent textContent: - parts.Add(ChatMessageContentPart.CreateTextPart(textContent.Text)); - break; - - case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): - parts.Add(ChatMessageContentPart.CreateImagePart(uriContent.Uri)); - break; - - case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): - parts.Add(ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType)); - break; - - case DataContent dataContent when dataContent.HasTopLevelMediaType("audio"): - var audioData = BinaryData.FromBytes(dataContent.Data); - if (dataContent.MediaType.Equals("audio/mpeg", StringComparison.OrdinalIgnoreCase)) - { - parts.Add(ChatMessageContentPart.CreateInputAudioPart(audioData, ChatInputAudioFormat.Mp3)); - } - else if (dataContent.MediaType.Equals("audio/wav", StringComparison.OrdinalIgnoreCase)) - { - parts.Add(ChatMessageContentPart.CreateInputAudioPart(audioData, ChatInputAudioFormat.Wav)); - } - - break; - } - } - - if (parts.Count == 0) - { - parts.Add(ChatMessageContentPart.CreateTextPart(string.Empty)); - } - - return parts; - } - -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - private static readonly Func> _getMessagesAccessor = - (Func>) - typeof(ChatCompletionOptions).GetMethod("get_Messages", BindingFlags.NonPublic | BindingFlags.Instance)! - .CreateDelegate(typeof(Func>))!; - - private static readonly Func _getStreamAccessor = - (Func) - typeof(ChatCompletionOptions).GetMethod("get_Stream", BindingFlags.NonPublic | BindingFlags.Instance)! - .CreateDelegate(typeof(Func))!; - - private static readonly MethodInfo _getModelIdAccessor = - typeof(ChatCompletionOptions).GetMethod("get_Model", BindingFlags.NonPublic | BindingFlags.Instance)!; -#pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMappers.StreamingChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMappers.StreamingChatCompletion.cs deleted file mode 100644 index 56003a0d91e..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMappers.StreamingChatCompletion.cs +++ /dev/null @@ -1,207 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S103 // Lines should not be too long -#pragma warning disable CA1859 // Use concrete types when possible for improved performance - -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using OpenAI.Chat; - -namespace Microsoft.Extensions.AI; - -internal static partial class OpenAIModelMappers -{ - public static async IAsyncEnumerable ToOpenAIStreamingChatCompletionAsync( - IAsyncEnumerable updates, - JsonSerializerOptions options, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - List? toolCallUpdates = null; - ChatTokenUsage? chatTokenUsage = null; - - foreach (var content in update.Contents) - { - if (content is FunctionCallContent functionCallContent) - { - toolCallUpdates ??= []; - toolCallUpdates.Add(OpenAIChatModelFactory.StreamingChatToolCallUpdate( - index: toolCallUpdates.Count, - toolCallId: functionCallContent.CallId, - functionName: functionCallContent.Name, - functionArgumentsUpdate: new(JsonSerializer.SerializeToUtf8Bytes(functionCallContent.Arguments, options.GetTypeInfo(typeof(IDictionary)))))); - } - else if (content is UsageContent usageContent) - { - chatTokenUsage = ToOpenAIUsage(usageContent.Details); - } - } - - yield return OpenAIChatModelFactory.StreamingChatCompletionUpdate( - completionId: update.ResponseId ?? CreateCompletionId(), - model: update.ModelId, - createdAt: update.CreatedAt ?? DateTimeOffset.UtcNow, - role: ToOpenAIChatRole(update.Role), - finishReason: update.FinishReason is null ? null : ToOpenAIFinishReason(update.FinishReason), - contentUpdate: [.. ToOpenAIChatContent(update.Contents)], - toolCallUpdates: toolCallUpdates, - refusalUpdate: update.AdditionalProperties.GetValueOrDefault(nameof(StreamingChatCompletionUpdate.RefusalUpdate)), - contentTokenLogProbabilities: update.AdditionalProperties.GetValueOrDefault>(nameof(StreamingChatCompletionUpdate.ContentTokenLogProbabilities)), - refusalTokenLogProbabilities: update.AdditionalProperties.GetValueOrDefault>(nameof(StreamingChatCompletionUpdate.RefusalTokenLogProbabilities)), - systemFingerprint: update.AdditionalProperties.GetValueOrDefault(nameof(StreamingChatCompletionUpdate.SystemFingerprint)), - usage: chatTokenUsage); - } - } - - public static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( - IAsyncEnumerable updates, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - 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)) - { - // The role and finish reason may arrive during any update, but once they've arrived, the same value should be the same for all subsequent updates. - streamedRole ??= update.Role is ChatMessageRole role ? FromOpenAIChatRole(role) : null; - finishReason ??= update.FinishReason is OpenAI.Chat.ChatFinishReason reason ? FromOpenAIFinishReason(reason) : null; - responseId ??= update.CompletionId; - createdAt ??= update.CreatedAt; - modelId ??= update.Model; - fingerprint ??= update.SystemFingerprint; - - // Create the response content object. - ChatResponseUpdate responseUpdate = new() - { - ResponseId = update.CompletionId, - MessageId = update.CompletionId, // There is no per-message ID, but there's only one message per response, so use the response ID - CreatedAt = update.CreatedAt, - FinishReason = finishReason, - ModelId = modelId, - RawRepresentation = update, - 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 }) - { - foreach (ChatMessageContentPart contentPart in update.ContentUpdate) - { - if (ToAIContent(contentPart) is AIContent aiContent) - { - responseUpdate.Contents.Add(aiContent); - } - } - } - - // 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) - { - foreach (StreamingChatToolCallUpdate toolCallUpdate in toolCallUpdates) - { - functionCallInfos ??= []; - if (!functionCallInfos.TryGetValue(toolCallUpdate.Index, out FunctionCallInfo? existing)) - { - functionCallInfos[toolCallUpdate.Index] = existing = new(); - } - - existing.CallId ??= toolCallUpdate.ToolCallId; - existing.Name ??= toolCallUpdate.FunctionName; - if (toolCallUpdate.FunctionArgumentsUpdate is { } argUpdate && !argUpdate.ToMemory().IsEmpty) - { - _ = (existing.Arguments ??= new()).Append(argUpdate.ToString()); - } - } - } - - // Transfer over usage updates. - if (update.Usage is ChatTokenUsage tokenUsage) - { - var usageDetails = FromOpenAIUsage(tokenUsage); - responseUpdate.Contents.Add(new UsageContent(usageDetails)); - } - - // Now yield the item. - yield return responseUpdate; - } - - // Now that we've received all updates, combine any for function calls into a single item to yield. - if (functionCallInfos is not null) - { - ChatResponseUpdate responseUpdate = new() - { - ResponseId = responseId, - MessageId = responseId, // There is no per-message ID, but there's only one message per response, so use the response ID - CreatedAt = createdAt, - FinishReason = finishReason, - ModelId = modelId, - Role = streamedRole, - }; - - foreach (var entry in functionCallInfos) - { - FunctionCallInfo fci = entry.Value; - if (!string.IsNullOrWhiteSpace(fci.Name)) - { - var callContent = ParseCallContentFromJsonString( - fci.Arguments?.ToString() ?? string.Empty, - fci.CallId!, - fci.Name!); - responseUpdate.Contents.Add(callContent); - } - } - - // 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; - } - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs deleted file mode 100644 index 06af9700a3e..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs +++ /dev/null @@ -1,142 +0,0 @@ -// 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.Linq; -using System.Text.Json; -using System.Text.Json.Serialization.Metadata; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; -using OpenAI.RealtimeConversation; - -namespace Microsoft.Extensions.AI; - -/// -/// Provides extension methods for working with and related types. -/// -public static class OpenAIRealtimeExtensions -{ - /// - /// Converts a into a so that - /// it can be used with . - /// - /// A that can be used with . - /// is . - public static ConversationFunctionTool ToConversationFunctionTool(this AIFunction aiFunction) - { - _ = Throw.IfNull(aiFunction); - - ConversationFunctionToolParametersSchema functionToolSchema = JsonSerializer.Deserialize(aiFunction.JsonSchema, OpenAIJsonContext.Default.ConversationFunctionToolParametersSchema)!; - BinaryData functionParameters = new(JsonSerializer.SerializeToUtf8Bytes(functionToolSchema, OpenAIJsonContext.Default.ConversationFunctionToolParametersSchema)); - return new ConversationFunctionTool(aiFunction.Name) - { - Description = aiFunction.Description, - Parameters = functionParameters - }; - } - - /// - /// Handles tool calls. - /// - /// If the represents a tool call, calls the corresponding tool and - /// adds the result to the . - /// - /// If the represents the end of a response, checks if this was due - /// to a tool call and if so, instructs the to begin responding to it. - /// - /// The . - /// The being processed. - /// The available tools. - /// An optional flag specifying whether to disclose detailed exception information to the model. The default value is . - /// An optional that controls JSON handling. - /// An optional to use for resolving services required by instances being invoked. - /// An optional . - /// A that represents the completion of processing, including invoking any asynchronous tools. - /// is . - /// is . - /// is . - public static async Task HandleToolCallsAsync( - this RealtimeConversationSession session, - ConversationUpdate update, - IReadOnlyList tools, - bool? detailedErrors = false, - JsonSerializerOptions? jsonSerializerOptions = null, - IServiceProvider? functionInvocationServices = null, - CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(session); - _ = Throw.IfNull(update); - _ = Throw.IfNull(tools); - - if (update is ConversationItemStreamingFinishedUpdate itemFinished) - { - // If we need to call a tool to update the model, do so - if (!string.IsNullOrEmpty(itemFinished.FunctionName) - && await itemFinished.GetFunctionCallOutputAsync(tools, detailedErrors, jsonSerializerOptions, functionInvocationServices, cancellationToken).ConfigureAwait(false) is { } output) - { - await session.AddItemAsync(output, cancellationToken).ConfigureAwait(false); - } - } - else if (update is ConversationResponseFinishedUpdate responseFinished) - { - // If we added one or more function call results, instruct the model to respond to them - if (responseFinished.CreatedItems.Any(item => !string.IsNullOrEmpty(item.FunctionName))) - { - await session!.StartResponseAsync(cancellationToken).ConfigureAwait(false); - } - } - } - - private static async Task GetFunctionCallOutputAsync( - this ConversationItemStreamingFinishedUpdate update, - IReadOnlyList tools, - bool? detailedErrors = false, - JsonSerializerOptions? jsonSerializerOptions = null, - IServiceProvider? functionInvocationServices = null, - CancellationToken cancellationToken = default) - { - if (!string.IsNullOrEmpty(update.FunctionName) - && tools.FirstOrDefault(t => t.Name == update.FunctionName) is AIFunction aiFunction) - { - var jsonOptions = jsonSerializerOptions ?? AIJsonUtilities.DefaultOptions; - - var functionCallContent = FunctionCallContent.CreateFromParsedArguments( - update.FunctionCallArguments, update.FunctionCallId, update.FunctionName, - argumentParser: json => JsonSerializer.Deserialize(json, - (JsonTypeInfo>)jsonOptions.GetTypeInfo(typeof(IDictionary)))!); - - try - { - var result = await aiFunction.InvokeAsync(new(functionCallContent.Arguments) { Services = functionInvocationServices }, cancellationToken).ConfigureAwait(false); - var resultJson = JsonSerializer.Serialize(result, jsonOptions.GetTypeInfo(typeof(object))); - return ConversationItem.CreateFunctionCallOutput(update.FunctionCallId, resultJson); - } - catch (JsonException) - { - return ConversationItem.CreateFunctionCallOutput(update.FunctionCallId, "Invalid JSON"); - } - catch (Exception e) when (!cancellationToken.IsCancellationRequested) - { - var message = "Error calling tool"; - - if (detailedErrors == true) - { - message += $": {e.Message}"; - } - - return ConversationItem.CreateFunctionCallOutput(update.FunctionCallId, message); - } - } - - return null; - } - - internal sealed class ConversationFunctionToolParametersSchema - { - public string? Type { get; set; } - public IDictionary? Properties { get; set; } - public IEnumerable? Required { get; set; } - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index 1922817a314..566aac8fa63 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -8,24 +8,24 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; using OpenAI.Responses; +using static Microsoft.Extensions.AI.OpenAIChatClient; #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 S3604 // Member initializer values should not be redundant -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable SA1108 // Block statements should not contain embedded comments namespace Microsoft.Extensions.AI; /// Represents an for an . -internal sealed class OpenAIResponseChatClient : IChatClient +internal sealed partial class OpenAIResponseChatClient : IChatClient { /// Gets the default OpenAI endpoint. - internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); + private static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); /// A for "developer". private static readonly ChatRole _chatRoleDeveloper = new("developer"); @@ -36,9 +36,6 @@ internal sealed class OpenAIResponseChatClient : IChatClient /// The underlying . private readonly OpenAIResponseClient _responseClient; - /// The use for any serialization activities related to tool call arguments and results. - private JsonSerializerOptions _toolCallJsonSerializerOptions = AIJsonUtilities.DefaultOptions; - /// Initializes a new instance of the class for the specified . /// The underlying client. /// is . @@ -60,13 +57,6 @@ public OpenAIResponseChatClient(OpenAIResponseClient responseClient) _metadata = new("openai", providerUrl, model); } - /// Gets or sets to use for any serialization activities related to tool call arguments and results. - public JsonSerializerOptions ToolCallJsonSerializerOptions - { - get => _toolCallJsonSerializerOptions; - set => _toolCallJsonSerializerOptions = Throw.IfNull(value); - } - /// object? IChatClient.GetService(Type serviceType, object? serviceKey) { @@ -87,7 +77,7 @@ public async Task GetResponseAsync( _ = Throw.IfNull(messages); // Convert the inputs into what OpenAIResponseClient expects. - var openAIResponseItems = ToOpenAIResponseItems(messages, ToolCallJsonSerializerOptions); + var openAIResponseItems = ToOpenAIResponseItems(messages); var openAIOptions = ToOpenAIResponseCreationOptions(options); // Make the call to the OpenAIResponseClient. @@ -138,7 +128,7 @@ public async Task GetResponseAsync( functionCall.FunctionArguments.ToMemory(), functionCall.CallId, functionCall.FunctionName, - static json => JsonSerializer.Deserialize(json.Span, OpenAIJsonContext.Default.IDictionaryStringObject)!)); + static json => JsonSerializer.Deserialize(json.Span, ResponseClientJsonContext.Default.IDictionaryStringObject)!)); break; } } @@ -154,7 +144,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( _ = Throw.IfNull(messages); // Convert the inputs into what OpenAIResponseClient expects. - var openAIResponseItems = ToOpenAIResponseItems(messages, ToolCallJsonSerializerOptions); + var openAIResponseItems = ToOpenAIResponseItems(messages); var openAIOptions = ToOpenAIResponseCreationOptions(options); // Make the call to the OpenAIResponseClient and process the streaming results. @@ -241,7 +231,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( callInfo.Arguments?.ToString() ?? string.Empty, callInfo.ResponseItem.CallId, callInfo.ResponseItem.FunctionName, - static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); + static json => JsonSerializer.Deserialize(json, ResponseClientJsonContext.Default.IDictionaryStringObject)!); lastMessageId = callInfo.ResponseItem.Id; lastRole = ChatRole.Assistant; @@ -345,8 +335,8 @@ private static ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptio switch (tool) { case AIFunction af: - var oaitool = JsonSerializer.Deserialize(af.JsonSchema, OpenAIJsonContext.Default.OpenAIChatToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(oaitool, OpenAIJsonContext.Default.OpenAIChatToolJson)); + var oaitool = JsonSerializer.Deserialize(af.JsonSchema, ResponseClientJsonContext.Default.ResponseToolJson)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(oaitool, ResponseClientJsonContext.Default.ResponseToolJson)); result.Tools.Add(ResponseTool.CreateFunctionTool(af.Name, af.Description, functionParameters)); break; @@ -403,7 +393,7 @@ private static ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptio TextFormat = jsonFormat.Schema is { } jsonSchema ? ResponseTextFormat.CreateJsonSchemaFormat( jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ResponseClientJsonContext.Default.JsonElement)), jsonFormat.SchemaDescription, jsonSchemaIsStrict: true) : ResponseTextFormat.CreateJsonObjectFormat(), @@ -416,7 +406,7 @@ private static ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptio /// Convert a sequence of s to s. private static IEnumerable ToOpenAIResponseItems( - IEnumerable inputs, JsonSerializerOptions options) + IEnumerable inputs) { foreach (ChatMessage input in inputs) { @@ -452,7 +442,7 @@ private static IEnumerable ToOpenAIResponseItems( { try { - result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object))); + result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -484,7 +474,7 @@ private static IEnumerable ToOpenAIResponseItems( callContent.Name, BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes( callContent.Arguments, - options.GetTypeInfo(typeof(IDictionary))))); + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); break; } } @@ -565,6 +555,22 @@ private static List ToOpenAIResponsesContent(IListUsed to create the JSON payload for an OpenAI chat tool description. + private sealed class ResponseToolJson + { + [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; } + } + /// POCO representing function calling info. /// Used to concatenation information for a single function call from across multiple streaming updates. private sealed class FunctionCallInfo(FunctionCallResponseItem item) @@ -572,4 +578,15 @@ private sealed class FunctionCallInfo(FunctionCallResponseItem item) public readonly FunctionCallResponseItem ResponseItem = item; public StringBuilder? Arguments; } + + /// Source-generated JSON type information. + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] + [JsonSerializable(typeof(ResponseToolJson))] + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(string[]))] + private sealed partial class ResponseClientJsonContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISerializationHelpers.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISerializationHelpers.cs deleted file mode 100644 index e736d110650..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISerializationHelpers.cs +++ /dev/null @@ -1,103 +0,0 @@ -// 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.Buffers; -using System.Collections.Generic; -using System.IO; -using System.Net.ServerSentEvents; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; -using OpenAI.Chat; - -namespace Microsoft.Extensions.AI; - -/// -/// Defines a set of helpers used to serialize Microsoft.Extensions.AI content using the OpenAI wire format. -/// -public static class OpenAISerializationHelpers -{ - /// - /// Deserializes a chat completion request in the OpenAI wire format into a pair of and values. - /// - /// The stream containing a message using the OpenAI wire format. - /// A token used to cancel the operation. - /// The deserialized list of chat messages and chat options. - /// is . - public static async Task DeserializeChatCompletionRequestAsync( - Stream stream, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(stream); - - BinaryData binaryData = await BinaryData.FromStreamAsync(stream, cancellationToken).ConfigureAwait(false); - ChatCompletionOptions openAiChatOptions = JsonModelHelpers.Deserialize(binaryData); - return OpenAIModelMappers.FromOpenAIChatCompletionRequest(openAiChatOptions); - } - - /// - /// Serializes a Microsoft.Extensions.AI response using the OpenAI wire format. - /// - /// The stream to write the value. - /// The chat response to serialize. - /// The governing function call content serialization. - /// A token used to cancel the serialization operation. - /// A task tracking the serialization operation. - /// is . - /// is . - public static async Task SerializeAsync( - Stream stream, - ChatResponse response, - JsonSerializerOptions? options = null, - CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(stream); - _ = Throw.IfNull(response); - options ??= AIJsonUtilities.DefaultOptions; - - ChatCompletion openAiChatResponse = OpenAIModelMappers.ToOpenAIChatCompletion(response, options); - BinaryData binaryData = JsonModelHelpers.Serialize(openAiChatResponse); - await stream.WriteAsync(binaryData.ToMemory(), cancellationToken).ConfigureAwait(false); - } - - /// - /// Serializes a Microsoft.Extensions.AI streaming response using the OpenAI wire format. - /// - /// The stream to write the value. - /// The chat response updates to serialize. - /// The governing function call content serialization. - /// A token used to cancel the serialization operation. - /// A task tracking the serialization operation. - /// is . - /// is . - public static Task SerializeStreamingAsync( - Stream stream, - IAsyncEnumerable updates, - JsonSerializerOptions? options = null, - CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(stream); - _ = Throw.IfNull(updates); - options ??= AIJsonUtilities.DefaultOptions; - - var mappedUpdates = OpenAIModelMappers.ToOpenAIStreamingChatCompletionAsync(updates, options, cancellationToken); - return SseFormatter.WriteAsync(ToSseEventsAsync(mappedUpdates), stream, FormatAsSseEvent, cancellationToken); - - static async IAsyncEnumerable> ToSseEventsAsync(IAsyncEnumerable updates) - { - await foreach (var update in updates.ConfigureAwait(false)) - { - BinaryData binaryData = JsonModelHelpers.Serialize(update); - yield return new(binaryData); - } - - yield return new(_finalSseEvent); - } - - static void FormatAsSseEvent(SseItem sseItem, IBufferWriter writer) => - writer.Write(sseItem.Data.ToMemory().Span); - } - - private static readonly BinaryData _finalSseEvent = new("[DONE]"u8.ToArray()); -} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md index f788900215a..4bbb2660f4e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md @@ -24,11 +24,10 @@ Or directly in the C# project file: ```csharp using Microsoft.Extensions.AI; -using OpenAI; IChatClient client = - new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) - .AsChatClient("gpt-4o-mini"); + new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIChatClient(); Console.WriteLine(await client.GetResponseAsync("What is AI?")); ``` @@ -37,11 +36,10 @@ Console.WriteLine(await client.GetResponseAsync("What is AI?")); ```csharp using Microsoft.Extensions.AI; -using OpenAI; IChatClient client = - new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) - .AsChatClient("gpt-4o-mini"); + new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIChatClient(); Console.WriteLine(await client.GetResponseAsync( [ @@ -54,11 +52,10 @@ Console.WriteLine(await client.GetResponseAsync( ```csharp using Microsoft.Extensions.AI; -using OpenAI; IChatClient client = - new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) - .AsChatClient("gpt-4o-mini"); + new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIChatClient(); await foreach (var update in client.GetStreamingResponseAsync("What is AI?")) { @@ -71,11 +68,10 @@ await foreach (var update in client.GetStreamingResponseAsync("What is AI?")) ```csharp using System.ComponentModel; using Microsoft.Extensions.AI; -using OpenAI; IChatClient openaiClient = - new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) - .AsChatClient("gpt-4o-mini"); + new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIChatClient(); IChatClient client = new ChatClientBuilder(openaiClient) .UseFunctionInvocation() @@ -102,13 +98,12 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using OpenAI; IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); IChatClient openaiClient = - new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) - .AsChatClient("gpt-4o-mini"); + new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIChatClient(); IChatClient client = new ChatClientBuilder(openaiClient) .UseDistributedCache(cache) @@ -130,7 +125,6 @@ for (int i = 0; i < 3; i++) ```csharp using Microsoft.Extensions.AI; -using OpenAI; using OpenTelemetry.Trace; // Configure OpenTelemetry exporter @@ -141,8 +135,8 @@ var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .Build(); IChatClient openaiClient = - new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) - .AsChatClient("gpt-4o-mini"); + new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIChatClient(); IChatClient client = new ChatClientBuilder(openaiClient) .UseOpenTelemetry(sourceName: sourceName, configure: c => c.EnableSensitiveData = true) @@ -159,7 +153,6 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using OpenAI; using OpenTelemetry.Trace; // Configure telemetry @@ -179,8 +172,8 @@ var chatOptions = new ChatOptions }; IChatClient openaiClient = - new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) - .AsChatClient("gpt-4o-mini"); + new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIChatClient(); IChatClient client = new ChatClientBuilder(openaiClient) .UseDistributedCache(cache) @@ -207,11 +200,10 @@ static int GetPersonAge(string personName) => ```csharp using Microsoft.Extensions.AI; -using OpenAI; IEmbeddingGenerator> generator = - new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) - .AsEmbeddingGenerator("text-embedding-3-small"); + new OpenAI.Embeddings.EmbeddingClient("text-embedding-3-small", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIEmbeddingGenerator(); var embeddings = await generator.GenerateAsync("What is AI?"); @@ -225,13 +217,12 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using OpenAI; IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); IEmbeddingGenerator> openAIGenerator = - new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) - .AsEmbeddingGenerator("text-embedding-3-small"); + new OpenAI.Embeddings.EmbeddingClient("text-embedding-3-small", Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsIEmbeddingGenerator(); IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>(openAIGenerator) .UseDistributedCache(cache) @@ -252,15 +243,14 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using OpenAI; // App Setup var builder = Host.CreateApplicationBuilder(); -builder.Services.AddSingleton(new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY"))); builder.Services.AddDistributedMemoryCache(); builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); -builder.Services.AddChatClient(services => services.GetRequiredService().AsChatClient("gpt-4o-mini")) +builder.Services.AddChatClient(services => + new OpenAI.Chat.ChatClient("gpt-4o-mini", Environment.GetEnvironmentVariable("OPENAI_API_KEY")).AsIChatClient()) .UseDistributedCache() .UseLogging(); @@ -275,17 +265,14 @@ Console.WriteLine(await chatClient.GetResponseAsync("What is AI?")); ```csharp using Microsoft.Extensions.AI; -using OpenAI; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSingleton(new OpenAIClient(builder.Configuration["OPENAI_API_KEY"])); - builder.Services.AddChatClient(services => - services.GetRequiredService().AsChatClient("gpt-4o-mini")); + new OpenAI.Chat.ChatClient("gpt-4o-mini", builder.Configuration["OPENAI_API_KEY"]).AsIChatClient()); builder.Services.AddEmbeddingGenerator(services => - services.GetRequiredService().AsEmbeddingGenerator("text-embedding-3-small")); + new OpenAI.Embeddings.EmbeddingClient("text-embedding-3-small", builder.Configuration["OPENAI_API_KEY"]).AsIEmbeddingGenerator()); var app = builder.Build(); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs index dbc3114ec25..bef118c2969 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs @@ -180,7 +180,7 @@ static async IAsyncEnumerable GetStreamingResponseAsyncViaGe } } - /// Throws an exception if both of the specified delegates are null. + /// Throws an exception if both of the specified delegates are . /// Both and are . internal static void ThrowIfBothDelegatesNull(object? getResponseFunc, object? getStreamingResponseFunc) { diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs index 8789810b601..bae7d58e4f9 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs @@ -36,7 +36,7 @@ public ChatClientBuilder(Func innerClientFactory) /// Builds an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. /// /// The that should provide services to the instances. - /// If null, an empty will be used. + /// If , an empty will be used. /// /// An instance of that represents the entire pipeline. public IChatClient Build(IServiceProvider? services = null) diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs index dcb33d37c3c..2ff5b6d2e1a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs @@ -41,7 +41,7 @@ public EmbeddingGeneratorBuilder(Func /// /// The that should provide services to the instances. - /// If null, an empty will be used. + /// If , an empty will be used. /// /// An instance of that represents the entire pipeline. public IEmbeddingGenerator Build(IServiceProvider? services = null) diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs index cf896a472c1..b0897bbf17d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -122,7 +122,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } - /// Creates an activity for an embedding generation request, or returns null if not enabled. + /// Creates an activity for an embedding generation request, or returns if not enabled. private Activity? CreateAndConfigureActivity(EmbeddingGenerationOptions? options) { Activity? activity = null; diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Program.cs index 98b47657881..aa7a4dbaa34 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData.Web-CSharp/Program.cs @@ -41,8 +41,8 @@ }; var ghModelsClient = new OpenAIClient(credential, openAIOptions); -var chatClient = ghModelsClient.AsChatClient("gpt-4o-mini"); -var embeddingGenerator = ghModelsClient.AsEmbeddingGenerator("text-embedding-3-small"); +var chatClient = ghModelsClient.GetChatClient("gpt-4o-mini").AsIChatClient(); +var embeddingGenerator = ghModelsClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); #elif (IsOllama) IChatClient chatClient = new OllamaApiClient(new Uri("http://localhost:11434"), "llama3.2"); @@ -55,8 +55,8 @@ // dotnet user-secrets set OpenAI:Key YOUR-API-KEY var openAIClient = new OpenAIClient( new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); -var chatClient = openAIClient.AsChatClient("gpt-4o-mini"); -var embeddingGenerator = openAIClient.AsEmbeddingGenerator("text-embedding-3-small"); +var chatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient(); +var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); #elif (IsAzureAiFoundry) #else @@ -74,8 +74,8 @@ #else new ApiKeyCredential(builder.Configuration["AzureOpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Key. See the README for details."))); #endif -var chatClient = azureOpenAi.AsChatClient("gpt-4o-mini"); -var embeddingGenerator = azureOpenAi.AsEmbeddingGenerator("text-embedding-3-small"); +var chatClient = azureOpenAi.GetChatClient("gpt-4o-mini").AsIChatClient(); +var embeddingGenerator = azureOpenAi.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); #endif #if (UseAzureAISearch) diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs index 1a4c0921838..a5f78eef135 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs @@ -7,5 +7,5 @@ public class AzureAIInferenceChatClientIntegrationTests : ChatClientIntegrationT { protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetChatCompletionsClient() - ?.AsChatClient(TestRunnerConfiguration.Instance["AzureAIInference:ChatModel"] ?? "gpt-4o-mini"); + ?.AsIChatClient(TestRunnerConfiguration.Instance["AzureAIInference:ChatModel"] ?? "gpt-4o-mini"); } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 92918ce1c51..4ed2c2f7eb9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -24,45 +24,23 @@ namespace Microsoft.Extensions.AI; public class AzureAIInferenceChatClientTests { [Fact] - public void Ctor_InvalidArgs_Throws() + public void AsIChatClient_InvalidArgs_Throws() { - Assert.Throws("chatCompletionsClient", () => new AzureAIInferenceChatClient(null!, "model")); + Assert.Throws("chatCompletionsClient", () => ((ChatCompletionsClient)null!).AsIChatClient("model")); ChatCompletionsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); - Assert.Throws("modelId", () => new AzureAIInferenceChatClient(client, " ")); + Assert.Throws("defaultModelId", () => client.AsIChatClient(" ")); } [Fact] - public void ToolCallJsonSerializerOptions_HasExpectedValue() - { - using AzureAIInferenceChatClient client = new(new(new("http://somewhere"), new AzureKeyCredential("key")), "mode"); - - Assert.Same(client.ToolCallJsonSerializerOptions, AIJsonUtilities.DefaultOptions); - Assert.Throws("value", () => client.ToolCallJsonSerializerOptions = null!); - - JsonSerializerOptions options = new(); - client.ToolCallJsonSerializerOptions = options; - Assert.Same(options, client.ToolCallJsonSerializerOptions); - } - - [Fact] - public void AsChatClient_InvalidArgs_Throws() - { - Assert.Throws("chatCompletionsClient", () => ((ChatCompletionsClient)null!).AsChatClient("model")); - - ChatCompletionsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); - Assert.Throws("modelId", () => client.AsChatClient(" ")); - } - - [Fact] - public void AsChatClient_ProducesExpectedMetadata() + public void AsIChatClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; ChatCompletionsClient client = new(endpoint, new AzureKeyCredential("key")); - IChatClient chatClient = client.AsChatClient(model); + IChatClient chatClient = client.AsIChatClient(model); var metadata = chatClient.GetService(); Assert.Equal("az.ai.inference", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); @@ -73,11 +51,9 @@ public void AsChatClient_ProducesExpectedMetadata() public void GetService_SuccessfullyReturnsUnderlyingClient() { ChatCompletionsClient client = new(new("http://localhost"), new AzureKeyCredential("key")); - IChatClient chatClient = client.AsChatClient("model"); + IChatClient chatClient = client.AsIChatClient("model"); Assert.Same(chatClient, chatClient.GetService()); - Assert.Same(chatClient, chatClient.GetService()); - Assert.Same(client, chatClient.GetService()); using IChatClient pipeline = chatClient @@ -97,7 +73,6 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.IsType(pipeline.GetService()); Assert.Null(pipeline.GetService("key")); - Assert.Null(pipeline.GetService("key")); Assert.Null(pipeline.GetService("key")); } @@ -918,5 +893,5 @@ private static IChatClient CreateChatClient(HttpClient httpClient, string modelI new("http://somewhere"), new AzureKeyCredential("key"), new AzureAIInferenceClientOptions { Transport = new HttpClientTransport(httpClient) }) - .AsChatClient(modelId); + .AsIChatClient(modelId); } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs index a5afde64578..7895f3bc10a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs @@ -7,5 +7,5 @@ public class AzureAIInferenceEmbeddingGeneratorIntegrationTests : EmbeddingGener { protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => IntegrationTestHelpers.GetEmbeddingsClient() - ?.AsEmbeddingGenerator(TestRunnerConfiguration.Instance["AzureAIInference:EmbeddingModel"] ?? "text-embedding-3-small"); + ?.AsIEmbeddingGenerator(TestRunnerConfiguration.Instance["AzureAIInference:EmbeddingModel"] ?? "text-embedding-3-small"); } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs index aacffe591b8..baee2555990 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs @@ -18,37 +18,25 @@ namespace Microsoft.Extensions.AI; public class AzureAIInferenceEmbeddingGeneratorTests { [Fact] - public void Ctor_InvalidArgs_Throws() + public void AsIEmbeddingGenerator_InvalidArgs_Throws() { - Assert.Throws("embeddingsClient", () => new AzureAIInferenceEmbeddingGenerator(null!)); + Assert.Throws("embeddingsClient", () => ((EmbeddingsClient)null!).AsIEmbeddingGenerator()); EmbeddingsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); - Assert.Throws("modelId", () => new AzureAIInferenceEmbeddingGenerator(client, "")); - Assert.Throws("modelId", () => new AzureAIInferenceEmbeddingGenerator(client, " ")); + Assert.Throws("defaultModelId", () => client.AsIEmbeddingGenerator(" ")); - using var _ = new AzureAIInferenceEmbeddingGenerator(client); + client.AsIEmbeddingGenerator(null); } [Fact] - public void AsEmbeddingGenerator_InvalidArgs_Throws() - { - Assert.Throws("embeddingsClient", () => ((EmbeddingsClient)null!).AsEmbeddingGenerator()); - - EmbeddingsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); - Assert.Throws("modelId", () => client.AsEmbeddingGenerator(" ")); - - client.AsEmbeddingGenerator(null); - } - - [Fact] - public void AsEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() + public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; EmbeddingsClient client = new(endpoint, new AzureKeyCredential("key")); - IEmbeddingGenerator> embeddingGenerator = client.AsEmbeddingGenerator(model); + IEmbeddingGenerator> embeddingGenerator = client.AsIEmbeddingGenerator(model); var metadata = embeddingGenerator.GetService(); Assert.Equal("az.ai.inference", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); @@ -59,7 +47,7 @@ public void AsEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() public void GetService_SuccessfullyReturnsUnderlyingClient() { var client = new EmbeddingsClient(new("http://somewhere"), new AzureKeyCredential("key")); - var embeddingGenerator = client.AsEmbeddingGenerator("model"); + var embeddingGenerator = client.AsIEmbeddingGenerator("model"); Assert.Same(embeddingGenerator, embeddingGenerator.GetService>>()); Assert.Same(client, embeddingGenerator.GetService()); @@ -113,7 +101,7 @@ public async Task GenerateAsync_ExpectedRequestResponse() using IEmbeddingGenerator> generator = new EmbeddingsClient(new("http://somewhere"), new AzureKeyCredential("key"), new() { Transport = new HttpClientTransport(httpClient), - }).AsEmbeddingGenerator("text-embedding-3-small"); + }).AsIEmbeddingGenerator("text-embedding-3-small"); var response = await generator.GenerateAsync([ "hello, world!", diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs index df84f2c9d9b..6f08cf52347 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs @@ -21,13 +21,13 @@ internal static class IntegrationTestHelpers TestRunnerConfiguration.Instance["AzureAIInference:Endpoint"] ?? "https://api.openai.com/v1"; - /// Gets a to use for testing, or null if the associated tests should be disabled. + /// Gets a to use for testing, or if the associated tests should be disabled. public static ChatCompletionsClient? GetChatCompletionsClient() => _apiKey is string apiKey ? new ChatCompletionsClient(new Uri(_endpoint), new AzureKeyCredential(apiKey), CreateOptions()) : null; - /// Gets an to use for testing, or null if the associated tests should be disabled. + /// Gets an to use for testing, or if the associated tests should be disabled. public static EmbeddingsClient? GetEmbeddingsClient() => _apiKey is string apiKey ? new EmbeddingsClient(new Uri(_endpoint), new AzureKeyCredential(apiKey), CreateOptions()) : diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs index 2c9fe3cfdda..aea0be7eb3f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs @@ -17,13 +17,13 @@ internal static class Setup internal static ChatConfiguration CreateChatConfiguration() { var endpoint = new Uri(Settings.Current.Endpoint); - AzureOpenAIClientOptions options = new AzureOpenAIClientOptions(); + AzureOpenAIClientOptions options = new(); AzureOpenAIClient azureClient = OfflineOnly ? new AzureOpenAIClient(endpoint, new ApiKeyCredential("Bogus"), options) : new AzureOpenAIClient(endpoint, new DefaultAzureCredential(), options); - IChatClient chatClient = azureClient.AsChatClient(Settings.Current.DeploymentName); + IChatClient chatClient = azureClient.GetChatClient(Settings.Current.DeploymentName).AsIChatClient(); Tokenizer tokenizer = TiktokenTokenizer.CreateForModel(Settings.Current.ModelName); IEvaluationTokenCounter tokenCounter = tokenizer.ToTokenCounter(inputTokenLimit: 6000); return new ChatConfiguration(chatClient, tokenCounter); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs index 99533e56f53..f2ec8ebdba0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs @@ -99,7 +99,7 @@ public interface IChatReducer /// Reduces the size of a list of chat messages. /// The messages. /// The to monitor for cancellation requests. The default is . - /// The new list of messages, or null if no reduction need be performed or was true. + /// The new list of messages, or if no reduction need be performed or was true. Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs index 2cd17719e74..25665ed9626 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI; /// Shared utility methods for integration tests. internal static class IntegrationTestHelpers { - /// Gets a to use for testing, or null if the associated tests should be disabled. + /// Gets a to use for testing, or if the associated tests should be disabled. public static Uri? GetOllamaUri() { return TestRunnerConfiguration.Instance["Ollama:Endpoint"] is string endpoint diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs index 1f92fea6bc6..2f716d2fe7d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs @@ -65,7 +65,7 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() } [Fact] - public void AsChatClient_ProducesExpectedMetadata() + public void Ctor_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs index 9ccdd79197c..be18138de84 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs @@ -44,7 +44,7 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() } [Fact] - public void AsEmbeddingGenerator_ProducesExpectedMetadata() + public void AsIEmbeddingGenerator_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs index be0cb85daf6..4b7252965f0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs @@ -12,7 +12,7 @@ namespace Microsoft.Extensions.AI; /// Shared utility methods for integration tests. internal static class IntegrationTestHelpers { - /// Gets an to use for testing, or null if the associated tests should be disabled. + /// Gets an to use for testing, or if the associated tests should be disabled. public static OpenAIClient? GetOpenAIClient() { var configuration = TestRunnerConfiguration.Instance; diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs index 04ea982d854..6322e3d6b64 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs @@ -7,5 +7,5 @@ public class OpenAIChatClientIntegrationTests : ChatClientIntegrationTests { protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetOpenAIClient() - ?.AsChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini"); + ?.GetChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini").AsIChatClient(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index fe9a5c019f1..ae7c83e2237 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -8,7 +8,6 @@ using System.ComponentModel; using System.Linq; using System.Net.Http; -using System.Text.Json; using System.Threading.Tasks; using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; @@ -24,45 +23,15 @@ namespace Microsoft.Extensions.AI; public class OpenAIChatClientTests { [Fact] - public void Ctor_InvalidArgs_Throws() + public void AsIChatClient_InvalidArgs_Throws() { - Assert.Throws("openAIClient", () => new OpenAIChatClient(null!, "model")); - Assert.Throws("chatClient", () => new OpenAIChatClient(null!)); - - OpenAIClient openAIClient = new("key"); - Assert.Throws("modelId", () => new OpenAIChatClient(openAIClient, null!)); - Assert.Throws("modelId", () => new OpenAIChatClient(openAIClient, "")); - Assert.Throws("modelId", () => new OpenAIChatClient(openAIClient, " ")); - } - - [Fact] - public void ToolCallJsonSerializerOptions_HasExpectedValue() - { - using OpenAIChatClient client = new(new("key"), "model"); - - Assert.Same(client.ToolCallJsonSerializerOptions, AIJsonUtilities.DefaultOptions); - Assert.Throws("value", () => client.ToolCallJsonSerializerOptions = null!); - - JsonSerializerOptions options = new(); - client.ToolCallJsonSerializerOptions = options; - Assert.Same(options, client.ToolCallJsonSerializerOptions); - } - - [Fact] - public void AsChatClient_InvalidArgs_Throws() - { - Assert.Throws("openAIClient", () => ((OpenAIClient)null!).AsChatClient("model")); - Assert.Throws("chatClient", () => ((ChatClient)null!).AsChatClient()); - - OpenAIClient client = new("key"); - Assert.Throws("modelId", () => client.AsChatClient(null!)); - Assert.Throws("modelId", () => client.AsChatClient(" ")); + Assert.Throws("chatClient", () => ((ChatClient)null!).AsIChatClient()); } [Theory] [InlineData(false)] [InlineData(true)] - public void AsChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; @@ -71,13 +40,13 @@ public void AsChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpen new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); - IChatClient chatClient = client.AsChatClient(model); + IChatClient chatClient = client.GetChatClient(model).AsIChatClient(); var metadata = chatClient.GetService(); Assert.Equal("openai", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); Assert.Equal(model, metadata?.DefaultModelId); - chatClient = client.GetChatClient(model).AsChatClient(); + chatClient = client.GetChatClient(model).AsIChatClient(); metadata = chatClient.GetService(); Assert.Equal("openai", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); @@ -87,13 +56,12 @@ public void AsChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpen [Fact] public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() { - OpenAIClient openAIClient = new(new ApiKeyCredential("key")); - IChatClient chatClient = openAIClient.AsChatClient("model"); + ChatClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetChatClient("model"); + IChatClient chatClient = openAIClient.AsIChatClient(); Assert.Same(chatClient, chatClient.GetService()); - Assert.Same(chatClient, chatClient.GetService()); - Assert.Same(openAIClient, chatClient.GetService()); + Assert.Same(openAIClient, chatClient.GetService()); Assert.NotNull(chatClient.GetService()); @@ -109,7 +77,7 @@ public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() Assert.NotNull(pipeline.GetService()); Assert.NotNull(pipeline.GetService()); - Assert.Same(openAIClient, pipeline.GetService()); + Assert.Same(openAIClient, pipeline.GetService()); Assert.IsType(pipeline.GetService()); } @@ -117,7 +85,7 @@ public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() public void GetService_ChatClient_SuccessfullyReturnsUnderlyingClient() { ChatClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetChatClient("model"); - IChatClient chatClient = openAIClient.AsChatClient(); + IChatClient chatClient = openAIClient.AsIChatClient(); Assert.Same(chatClient, chatClient.GetService()); Assert.Same(openAIClient, chatClient.GetService()); @@ -1067,5 +1035,6 @@ public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() private static IChatClient CreateChatClient(HttpClient httpClient, string modelId) => new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) - .AsChatClient(modelId); + .GetChatClient(modelId) + .AsIChatClient(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs index 2c48e3287df..05908987fa9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs @@ -7,5 +7,5 @@ public class OpenAIEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegr { protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => IntegrationTestHelpers.GetOpenAIClient() - ?.AsEmbeddingGenerator(TestRunnerConfiguration.Instance["OpenAI:EmbeddingModel"] ?? "text-embedding-3-small"); + ?.GetEmbeddingClient(TestRunnerConfiguration.Instance["OpenAI:EmbeddingModel"] ?? "text-embedding-3-small").AsIEmbeddingGenerator(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index 72864576529..0caa50935f2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -20,32 +20,15 @@ namespace Microsoft.Extensions.AI; public class OpenAIEmbeddingGeneratorTests { [Fact] - public void Ctor_InvalidArgs_Throws() + public void AsIEmbeddingGenerator_InvalidArgs_Throws() { - Assert.Throws("openAIClient", () => new OpenAIEmbeddingGenerator(null!, "model")); - Assert.Throws("embeddingClient", () => new OpenAIEmbeddingGenerator(null!)); - - OpenAIClient openAIClient = new("key"); - Assert.Throws("modelId", () => new OpenAIEmbeddingGenerator(openAIClient, null!)); - Assert.Throws("modelId", () => new OpenAIEmbeddingGenerator(openAIClient, "")); - Assert.Throws("modelId", () => new OpenAIEmbeddingGenerator(openAIClient, " ")); - } - - [Fact] - public void AsEmbeddingGenerator_InvalidArgs_Throws() - { - Assert.Throws("openAIClient", () => ((OpenAIClient)null!).AsEmbeddingGenerator("model")); - Assert.Throws("embeddingClient", () => ((EmbeddingClient)null!).AsEmbeddingGenerator()); - - OpenAIClient client = new("key"); - Assert.Throws("modelId", () => client.AsEmbeddingGenerator(null!)); - Assert.Throws("modelId", () => client.AsEmbeddingGenerator(" ")); + Assert.Throws("embeddingClient", () => ((EmbeddingClient)null!).AsIEmbeddingGenerator()); } [Theory] [InlineData(false)] [InlineData(true)] - public void AsEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; @@ -54,13 +37,13 @@ public void AsEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata(bool useA new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); - IEmbeddingGenerator> embeddingGenerator = client.AsEmbeddingGenerator(model); + IEmbeddingGenerator> embeddingGenerator = client.GetEmbeddingClient(model).AsIEmbeddingGenerator(); var metadata = embeddingGenerator.GetService(); Assert.Equal("openai", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); Assert.Equal(model, metadata?.DefaultModelId); - embeddingGenerator = client.GetEmbeddingClient(model).AsEmbeddingGenerator(); + embeddingGenerator = client.GetEmbeddingClient(model).AsIEmbeddingGenerator(); Assert.Equal("openai", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); Assert.Equal(model, metadata?.DefaultModelId); @@ -69,13 +52,12 @@ public void AsEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata(bool useA [Fact] public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() { - OpenAIClient openAIClient = new(new ApiKeyCredential("key")); - IEmbeddingGenerator> embeddingGenerator = openAIClient.AsEmbeddingGenerator("model"); + EmbeddingClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetEmbeddingClient("model"); + IEmbeddingGenerator> embeddingGenerator = openAIClient.AsIEmbeddingGenerator(); Assert.Same(embeddingGenerator, embeddingGenerator.GetService>>()); - Assert.Same(embeddingGenerator, embeddingGenerator.GetService()); - Assert.Same(openAIClient, embeddingGenerator.GetService()); + Assert.Same(openAIClient, embeddingGenerator.GetService()); Assert.NotNull(embeddingGenerator.GetService()); @@ -89,7 +71,7 @@ public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() Assert.NotNull(pipeline.GetService>>()); Assert.NotNull(pipeline.GetService>>()); - Assert.Same(openAIClient, pipeline.GetService()); + Assert.Same(openAIClient, pipeline.GetService()); Assert.IsType>>(pipeline.GetService>>()); } @@ -97,7 +79,7 @@ public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() public void GetService_EmbeddingClient_SuccessfullyReturnsUnderlyingClient() { EmbeddingClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetEmbeddingClient("model"); - IEmbeddingGenerator> embeddingGenerator = openAIClient.AsEmbeddingGenerator(); + IEmbeddingGenerator> embeddingGenerator = openAIClient.AsIEmbeddingGenerator(); Assert.Same(embeddingGenerator, embeddingGenerator.GetService>>()); Assert.Same(openAIClient, embeddingGenerator.GetService()); @@ -151,7 +133,7 @@ public async Task GetEmbeddingsAsync_ExpectedRequestResponse() using IEmbeddingGenerator> generator = new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient), - }).AsEmbeddingGenerator("text-embedding-3-small"); + }).GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); var response = await generator.GenerateAsync([ "hello, world!", diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs deleted file mode 100644 index 5a5e7040159..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -// 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.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading.Tasks; -using Azure.AI.OpenAI; -using Microsoft.TestUtilities; -using OpenAI.RealtimeConversation; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OpenAIRealtimeIntegrationTests -{ - private RealtimeConversationClient? _conversationClient; - - public OpenAIRealtimeIntegrationTests() - { - _conversationClient = CreateConversationClient(); - } - - [ConditionalFact] - public async Task CanPerformFunctionCall() - { - SkipIfNotEnabled(); - - var roomCapacityTool = AIFunctionFactory.Create(GetRoomCapacity); - var sessionOptions = new ConversationSessionOptions - { - Instructions = "You help with booking appointments", - Tools = { roomCapacityTool.ToConversationFunctionTool() }, - ContentModalities = ConversationContentModalities.Text, - }; - - using var session = await _conversationClient.StartConversationSessionAsync(); - await session.ConfigureSessionAsync(sessionOptions); - - await foreach (var update in session.ReceiveUpdatesAsync()) - { - switch (update) - { - case ConversationSessionStartedUpdate: - await session.AddItemAsync( - ConversationItem.CreateUserMessage([""" - What type of room can hold the most people? - Reply with the full name of the biggest venue and its capacity only. - Do not mention the other venues. - """])); - await session.StartResponseAsync(); - break; - - case ConversationResponseFinishedUpdate responseFinished: - var content = responseFinished.CreatedItems - .SelectMany(i => i.MessageContentParts ?? []) - .OfType() - .FirstOrDefault(); - if (content is not null) - { - Assert.Contains("VehicleAssemblyBuilding", content.Text.Replace(" ", string.Empty)); - Assert.Contains("12000", content.Text.Replace(",", string.Empty)); - return; - } - - break; - } - - await session.HandleToolCallsAsync(update, [roomCapacityTool]); - } - } - - [Description("Returns the number of people that can fit in a room.")] - private static int GetRoomCapacity(RoomType roomType) - { - return roomType switch - { - RoomType.ShuttleSimulator => throw new InvalidOperationException("No longer available"), - RoomType.NorthAtlantisLawn => 450, - RoomType.VehicleAssemblyBuilding => 12000, - _ => throw new NotSupportedException($"Unknown room type: {roomType}"), - }; - } - - private enum RoomType - { - ShuttleSimulator, - NorthAtlantisLawn, - VehicleAssemblyBuilding, - } - - [MemberNotNull(nameof(_conversationClient))] - protected void SkipIfNotEnabled() - { - if (_conversationClient is null) - { - throw new SkipTestException("Client is not enabled."); - } - } - - private static RealtimeConversationClient? CreateConversationClient() - { - var realtimeModel = TestRunnerConfiguration.Instance["OpenAI:RealtimeModel"]; - if (string.IsNullOrEmpty(realtimeModel)) - { - return null; - } - - var openAiClient = (AzureOpenAIClient?)IntegrationTestHelpers.GetOpenAIClient(); - return openAiClient?.GetRealtimeConversationClient(realtimeModel); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeTests.cs deleted file mode 100644 index cfb135e9444..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -// 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.ClientModel; -using System.ComponentModel; -using System.Threading.Tasks; -using OpenAI.RealtimeConversation; -using Xunit; - -namespace Microsoft.Extensions.AI; - -// Note that we're limited on ability to unit-test OpenAIRealtimeExtension, because some of the -// OpenAI types it uses (e.g., ConversationItemStreamingFinishedUpdate) can't be instantiated or -// subclassed from outside. We will mostly have to rely on integration tests for now. - -public class OpenAIRealtimeTests -{ - [Fact] - public void ConvertsAIFunctionToConversationFunctionTool_Basics() - { - var input = AIFunctionFactory.Create(() => { }, "MyFunction", "MyDescription"); - var result = input.ToConversationFunctionTool(); - - Assert.Equal("MyFunction", result.Name); - Assert.Equal("MyDescription", result.Description); - } - - [Fact] - public void ConvertsAIFunctionToConversationFunctionTool_Parameters() - { - var input = AIFunctionFactory.Create(MyFunction); - var result = input.ToConversationFunctionTool(); - - Assert.Equal(nameof(MyFunction), result.Name); - Assert.Equal("This is a description", result.Description); - Assert.Equal(""" - { - "type": "object", - "properties": { - "a": { - "type": "integer" - }, - "b": { - "description": "Another param", - "type": "string" - }, - "c": { - "description": "Default value: null", - "type": "object", - "properties": { - "a": { - "type": "integer" - } - }, - "additionalProperties": false, - "required": [ - "a" - ] - } - }, - "required": [ - "a", - "b", - "c" - ] - } - """, result.Parameters.ToString()); - } - - [Fact] - public async Task HandleToolCallsAsync_RejectsNulls() - { - var conversationSession = (RealtimeConversationSession)default!; - - // There's currently no way to create a ConversationUpdate instance from outside of the OpenAI - // library, so we can't validate behavior when a valid ConversationUpdate instance is passed in. - - // Null ConversationUpdate - using var session = TestRealtimeConversationSession.CreateTestInstance(); - await Assert.ThrowsAsync(() => conversationSession.HandleToolCallsAsync( - null!, [])); - } - - [Description("This is a description")] - private MyType MyFunction(int a, [Description("Another param")] string b, MyType? c = null) - => throw new NotSupportedException(); - - public class MyType - { - public int A { get; set; } - } - - private class TestRealtimeConversationSession : RealtimeConversationSession - { - protected internal TestRealtimeConversationSession(RealtimeConversationClient parentClient, Uri endpoint, ApiKeyCredential credential) - : base(parentClient, endpoint, credential) - { - } - - public static TestRealtimeConversationSession CreateTestInstance() - { - var credential = new ApiKeyCredential("key"); - return new TestRealtimeConversationSession( - new RealtimeConversationClient("model", credential), - new Uri("http://endpoint"), credential); - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index b9fcf177f3c..bbfad1c571d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -8,5 +8,5 @@ public class OpenAIResponseClientIntegrationTests : ChatClientIntegrationTests protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetOpenAIClient() ?.GetOpenAIResponseClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini") - .AsChatClient(); + .AsIChatClient(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 578d94c10b7..717aae223f7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -22,20 +22,15 @@ namespace Microsoft.Extensions.AI; public class OpenAIResponseClientTests { [Fact] - public void AsChatClient_InvalidArgs_Throws() + public void AsIChatClient_InvalidArgs_Throws() { - Assert.Throws("openAIClient", () => ((OpenAIClient)null!).AsChatClient("model")); - Assert.Throws("responseClient", () => ((OpenAIResponseClient)null!).AsChatClient()); - - OpenAIClient client = new("key"); - Assert.Throws("modelId", () => client.AsChatClient(null!)); - Assert.Throws("modelId", () => client.AsChatClient(" ")); + Assert.Throws("responseClient", () => ((OpenAIResponseClient)null!).AsIChatClient()); } [Theory] [InlineData(false)] [InlineData(true)] - public void AsChatClient_ProducesExpectedMetadata(bool useAzureOpenAI) + public void AsIChatClient_ProducesExpectedMetadata(bool useAzureOpenAI) { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; @@ -44,7 +39,7 @@ public void AsChatClient_ProducesExpectedMetadata(bool useAzureOpenAI) new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); - IChatClient chatClient = client.GetOpenAIResponseClient(model).AsChatClient(); + IChatClient chatClient = client.GetOpenAIResponseClient(model).AsIChatClient(); var metadata = chatClient.GetService(); Assert.Equal("openai", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); @@ -55,7 +50,7 @@ public void AsChatClient_ProducesExpectedMetadata(bool useAzureOpenAI) public void GetService_SuccessfullyReturnsUnderlyingClient() { OpenAIResponseClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetOpenAIResponseClient("model"); - IChatClient chatClient = openAIClient.AsChatClient(); + IChatClient chatClient = openAIClient.AsIChatClient(); Assert.Same(chatClient, chatClient.GetService()); Assert.Same(openAIClient, chatClient.GetService()); @@ -134,7 +129,6 @@ public async Task BasicRequestResponse_NonStreaming() "tool_choice": "auto", "tools": [], "top_p": 1.0, - "truncation": "auto", "usage": { "input_tokens": 26, "input_tokens_details": { @@ -166,7 +160,6 @@ public async Task BasicRequestResponse_NonStreaming() Assert.Equal("Hello! How can I assist you today?", response.Text); Assert.Single(response.Messages.Single().Contents); Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal("msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", response.Messages.Single().MessageId); Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_741_891_428), response.CreatedAt); Assert.Null(response.FinishReason); @@ -198,10 +191,10 @@ public async Task BasicRequestResponse_Streaming() const string Output = """ event: response.created - data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"auto","usage":null,"user":null,"metadata":{}}} + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} event: response.in_progress - data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"auto","usage":null,"user":null,"metadata":{}}} + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} event: response.output_item.added data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} @@ -246,7 +239,7 @@ public async Task BasicRequestResponse_Streaming() data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello! How can I assist you today?","annotations":[]}]}} event: response.completed - data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello! How can I assist you today?","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"auto","usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello! How can I assist you today?","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} """; @@ -272,7 +265,6 @@ public async Task BasicRequestResponse_Streaming() for (int i = 0; i < updates.Count; i++) { Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); - Assert.Equal("msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77", updates[i].MessageId); Assert.Equal(createdAt, updates[i].CreatedAt); Assert.Equal("gpt-4o-mini-2024-07-18", updates[i].ModelId); Assert.Equal(ChatRole.Assistant, updates[i].Role); @@ -292,5 +284,5 @@ private static IChatClient CreateResponseClient(HttpClient httpClient, string mo new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) .GetOpenAIResponseClient(modelId) - .AsChatClient(); + .AsIChatClient(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs deleted file mode 100644 index d76df1a6cbe..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs +++ /dev/null @@ -1,754 +0,0 @@ -// 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.IO; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using OpenAI.Chat; -using Xunit; - -#pragma warning disable S103 // Lines should not be too long - -namespace Microsoft.Extensions.AI; - -public static partial class OpenAISerializationTests -{ - [Fact] - public static async Task RequestDeserialization_SimpleMessage() - { - const string RequestJson = """ - {"messages":[{"role":"user","content":"hello"}],"model":"gpt-4o-mini","max_completion_tokens":10,"temperature":0.5} - """; - - using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); - OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); - - Assert.NotNull(request); - Assert.False(request.Stream); - Assert.Equal("gpt-4o-mini", request.ModelId); - - Assert.NotNull(request.Options); - Assert.Equal("gpt-4o-mini", request.Options.ModelId); - Assert.Equal(0.5f, request.Options.Temperature); - Assert.Equal(10, request.Options.MaxOutputTokens); - Assert.Null(request.Options.TopK); - Assert.Null(request.Options.TopP); - Assert.Null(request.Options.StopSequences); - Assert.Null(request.Options.AdditionalProperties); - Assert.Null(request.Options.Tools); - - ChatMessage message = Assert.Single(request.Messages); - Assert.Equal(ChatRole.User, message.Role); - AIContent content = Assert.Single(message.Contents); - TextContent textContent = Assert.IsType(content); - Assert.Equal("hello", textContent.Text); - Assert.Null(textContent.RawRepresentation); - Assert.Null(textContent.AdditionalProperties); - } - - [Fact] - public static async Task RequestDeserialization_SimpleMessage_Stream() - { - const string RequestJson = """ - {"messages":[{"role":"user","content":"hello"}],"model":"gpt-4o-mini","max_completion_tokens":20,"stream":true,"stream_options":{"include_usage":true},"temperature":0.5} - """; - - using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); - OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); - - Assert.NotNull(request); - Assert.True(request.Stream); - Assert.Equal("gpt-4o-mini", request.ModelId); - - Assert.NotNull(request.Options); - Assert.Equal("gpt-4o-mini", request.Options.ModelId); - Assert.Equal(0.5f, request.Options.Temperature); - Assert.Equal(20, request.Options.MaxOutputTokens); - Assert.Null(request.Options.TopK); - Assert.Null(request.Options.TopP); - Assert.Null(request.Options.StopSequences); - Assert.Null(request.Options.AdditionalProperties); - Assert.Null(request.Options.Tools); - - ChatMessage message = Assert.Single(request.Messages); - Assert.Equal(ChatRole.User, message.Role); - AIContent content = Assert.Single(message.Contents); - TextContent textContent = Assert.IsType(content); - Assert.Equal("hello", textContent.Text); - Assert.Null(textContent.RawRepresentation); - Assert.Null(textContent.AdditionalProperties); - } - - [Fact] - public static void RequestDeserialization_SimpleMessage_JsonSerializer() - { - const string RequestJson = """ - {"messages":[{"role":"user","content":"hello"}],"model":"gpt-4o-mini","max_completion_tokens":20,"stream":true,"stream_options":{"include_usage":true},"temperature":0.5} - """; - - OpenAIChatCompletionRequest? request = JsonSerializer.Deserialize(RequestJson); - - Assert.NotNull(request); - Assert.True(request.Stream); - Assert.Equal("gpt-4o-mini", request.ModelId); - - Assert.NotNull(request.Options); - Assert.Equal("gpt-4o-mini", request.Options.ModelId); - Assert.Equal(0.5f, request.Options.Temperature); - Assert.Equal(20, request.Options.MaxOutputTokens); - Assert.Null(request.Options.TopK); - Assert.Null(request.Options.TopP); - Assert.Null(request.Options.StopSequences); - Assert.Null(request.Options.AdditionalProperties); - Assert.Null(request.Options.Tools); - - ChatMessage message = Assert.Single(request.Messages); - Assert.Equal(ChatRole.User, message.Role); - AIContent content = Assert.Single(message.Contents); - TextContent textContent = Assert.IsType(content); - Assert.Equal("hello", textContent.Text); - Assert.Null(textContent.RawRepresentation); - Assert.Null(textContent.AdditionalProperties); - } - - [Fact] - public static async Task RequestDeserialization_MultipleMessages() - { - const string RequestJson = """ - { - "messages": [ - { - "role": "system", - "content": "You are a really nice friend." - }, - { - "role": "user", - "content": "hello!" - }, - { - "role": "assistant", - "content": "hi, how are you?" - }, - { - "role": "user", - "content": "i\u0027m good. how are you?" - } - ], - "model": "gpt-4o-mini", - "frequency_penalty": 0.75, - "presence_penalty": 0.5, - "seed":42, - "stop": [ "great" ], - "temperature": 0.25, - "user": "user", - "logprobs": true, - "logit_bias": { "42" : 0 }, - "parallel_tool_calls": true, - "top_logprobs": 42, - "metadata": { "key": "value" }, - "store": true - } - """; - - using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); - OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); - - Assert.NotNull(request); - Assert.False(request.Stream); - Assert.Equal("gpt-4o-mini", request.ModelId); - - Assert.NotNull(request.Options); - Assert.Equal("gpt-4o-mini", request.Options.ModelId); - Assert.Equal(0.25f, request.Options.Temperature); - Assert.Equal(0.75f, request.Options.FrequencyPenalty); - Assert.Equal(0.5f, request.Options.PresencePenalty); - Assert.Equal(42, request.Options.Seed); - Assert.Equal(["great"], request.Options.StopSequences); - Assert.NotNull(request.Options.AdditionalProperties); - Assert.Equal("user", request.Options.AdditionalProperties["EndUserId"]); - Assert.True((bool)request.Options.AdditionalProperties["IncludeLogProbabilities"]!); - Assert.Single((IDictionary)request.Options.AdditionalProperties["LogitBiases"]!); - Assert.True((bool)request.Options.AdditionalProperties["AllowParallelToolCalls"]!); - Assert.Equal(42, request.Options.AdditionalProperties["TopLogProbabilityCount"]!); - Assert.Single((IDictionary)request.Options.AdditionalProperties["Metadata"]!); - Assert.True((bool)request.Options.AdditionalProperties["StoredOutputEnabled"]!); - - Assert.Collection(request.Messages, - msg => - { - Assert.Equal(ChatRole.System, msg.Role); - Assert.Null(msg.RawRepresentation); - Assert.Null(msg.AdditionalProperties); - - TextContent text = Assert.IsType(Assert.Single(msg.Contents)); - Assert.Equal("You are a really nice friend.", text.Text); - Assert.Null(text.AdditionalProperties); - Assert.Null(text.RawRepresentation); - Assert.Null(text.AdditionalProperties); - }, - msg => - { - Assert.Equal(ChatRole.User, msg.Role); - Assert.Null(msg.RawRepresentation); - Assert.Null(msg.AdditionalProperties); - - TextContent text = Assert.IsType(Assert.Single(msg.Contents)); - Assert.Equal("hello!", text.Text); - Assert.Null(text.AdditionalProperties); - Assert.Null(text.RawRepresentation); - Assert.Null(text.AdditionalProperties); - }, - msg => - { - Assert.Equal(ChatRole.Assistant, msg.Role); - Assert.Null(msg.RawRepresentation); - Assert.Null(msg.AdditionalProperties); - - TextContent text = Assert.IsType(Assert.Single(msg.Contents)); - Assert.Equal("hi, how are you?", text.Text); - Assert.Null(text.AdditionalProperties); - Assert.Null(text.RawRepresentation); - Assert.Null(text.AdditionalProperties); - }, - msg => - { - Assert.Equal(ChatRole.User, msg.Role); - Assert.Null(msg.RawRepresentation); - Assert.Null(msg.AdditionalProperties); - - TextContent text = Assert.IsType(Assert.Single(msg.Contents)); - Assert.Equal("i'm good. how are you?", text.Text); - Assert.Null(text.AdditionalProperties); - Assert.Null(text.RawRepresentation); - Assert.Null(text.AdditionalProperties); - }); - } - - [Fact] - public static async Task RequestDeserialization_MultiPartSystemMessage() - { - const string RequestJson = """ - { - "messages": [ - { - "role": "system", - "content": [ - { - "type": "text", - "text": "You are a really nice friend." - }, - { - "type": "text", - "text": "Really nice." - } - ] - }, - { - "role": "user", - "content": "hello!" - } - ], - "model": "gpt-4o-mini" - } - """; - - using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); - OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); - - Assert.NotNull(request); - Assert.False(request.Stream); - Assert.Equal("gpt-4o-mini", request.ModelId); - - Assert.NotNull(request.Options); - Assert.Equal("gpt-4o-mini", request.Options.ModelId); - Assert.Null(request.Options.Temperature); - Assert.Null(request.Options.FrequencyPenalty); - Assert.Null(request.Options.PresencePenalty); - Assert.Null(request.Options.Seed); - Assert.Null(request.Options.StopSequences); - - Assert.Collection(request.Messages, - msg => - { - Assert.Equal(ChatRole.System, msg.Role); - Assert.Null(msg.RawRepresentation); - Assert.Null(msg.AdditionalProperties); - - Assert.Collection(msg.Contents, - content => - { - TextContent text = Assert.IsType(content); - Assert.Equal("You are a really nice friend.", text.Text); - Assert.Null(text.AdditionalProperties); - Assert.Null(text.RawRepresentation); - Assert.Null(text.AdditionalProperties); - }, - content => - { - TextContent text = Assert.IsType(content); - Assert.Equal("Really nice.", text.Text); - Assert.Null(text.AdditionalProperties); - Assert.Null(text.RawRepresentation); - Assert.Null(text.AdditionalProperties); - }); - }, - msg => - { - Assert.Equal(ChatRole.User, msg.Role); - Assert.Null(msg.RawRepresentation); - Assert.Null(msg.AdditionalProperties); - - TextContent text = Assert.IsType(Assert.Single(msg.Contents)); - Assert.Equal("hello!", text.Text); - Assert.Null(text.AdditionalProperties); - Assert.Null(text.RawRepresentation); - Assert.Null(text.AdditionalProperties); - }); - } - - [Fact] - public static async Task RequestDeserialization_ToolCall() - { - const string RequestJson = """ - { - "messages": [ - { - "role": "user", - "content": "How old is Alice?" - } - ], - "model": "gpt-4o-mini", - "tools": [ - { - "type": "function", - "function": { - "description": "Gets the age of the specified person.", - "name": "GetPersonAge", - "strict": true, - "parameters": { - "type": "object", - "required": [ - "personName" - ], - "properties": { - "personName": { - "description": "The person whose age is being requested", - "type": "string" - } - } - } - } - } - ], - "tool_choice": "auto" - } - """; - - using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); - OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); - - Assert.NotNull(request); - Assert.False(request.Stream); - Assert.Equal("gpt-4o-mini", request.ModelId); - - Assert.NotNull(request.Options); - Assert.Equal("gpt-4o-mini", request.Options.ModelId); - Assert.Null(request.Options.Temperature); - Assert.Null(request.Options.FrequencyPenalty); - Assert.Null(request.Options.PresencePenalty); - Assert.Null(request.Options.Seed); - Assert.Null(request.Options.StopSequences); - - Assert.Same(ChatToolMode.Auto, request.Options.ToolMode); - Assert.NotNull(request.Options.Tools); - - AIFunction function = Assert.IsAssignableFrom(Assert.Single(request.Options.Tools)); - Assert.Equal("Gets the age of the specified person.", function.Description); - Assert.Equal("GetPersonAge", function.Name); - Assert.Equal("Strict", Assert.Single(function.AdditionalProperties).Key); - - Assert.Null(function.UnderlyingMethod); - - JsonObject parametersSchema = Assert.IsType(JsonNode.Parse(function.JsonSchema.GetProperty("properties").GetRawText())); - var parameterSchema = Assert.IsType(Assert.Single(parametersSchema.Select(kvp => kvp.Value))); - Assert.Equal(2, parameterSchema.Count); - Assert.Equal("The person whose age is being requested", (string)parameterSchema["description"]!); - Assert.Equal("string", (string)parameterSchema["type"]!); - - AIFunctionArguments functionArgs = new() { ["personName"] = "John" }; - var ex = await Assert.ThrowsAsync(() => function.InvokeAsync(functionArgs).AsTask()); - Assert.Contains("does not support being invoked.", ex.Message); - } - - [Fact] - public static async Task RequestDeserialization_ToolChatMessage() - { - const string RequestJson = """ - { - "messages": [ - { - "role": "assistant", - "tool_calls": [ - { - "id": "12345", - "type": "function", - "function": { - "name": "SayHello", - "arguments": "null" - } - } - ] - }, - { - "role": "tool", - "tool_call_id": "12345", - "content": "42" - } - ], - "model": "gpt-4o-mini" - } - """; - - using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); - OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); - - Assert.NotNull(request); - Assert.False(request.Stream); - Assert.Equal("gpt-4o-mini", request.ModelId); - - Assert.NotNull(request.Options); - Assert.Equal("gpt-4o-mini", request.Options.ModelId); - Assert.Null(request.Options.Temperature); - Assert.Null(request.Options.FrequencyPenalty); - Assert.Null(request.Options.PresencePenalty); - Assert.Null(request.Options.Seed); - Assert.Null(request.Options.StopSequences); - - Assert.Collection(request.Messages, - msg => - { - Assert.Equal(ChatRole.Assistant, msg.Role); - Assert.Null(msg.RawRepresentation); - Assert.Null(msg.AdditionalProperties); - - FunctionCallContent text = Assert.IsType(Assert.Single(msg.Contents)); - Assert.Equal("12345", text.CallId); - Assert.Null(text.AdditionalProperties); - Assert.IsType(text.RawRepresentation); - Assert.Null(text.AdditionalProperties); - }, - msg => - { - Assert.Equal(ChatRole.Tool, msg.Role); - Assert.Null(msg.RawRepresentation); - Assert.Null(msg.AdditionalProperties); - - FunctionResultContent frc = Assert.IsType(Assert.Single(msg.Contents)); - Assert.Equal("12345", frc.CallId); - Assert.Equal(42, Assert.IsType(frc.Result).GetInt32()); - Assert.Null(frc.AdditionalProperties); - Assert.Null(frc.RawRepresentation); - Assert.Null(frc.AdditionalProperties); - }); - } - - [Fact] - public static async Task SerializeResponse() - { - ChatMessage message = new() - { - Role = ChatRole.Assistant, - Contents = [ - new TextContent("Hello! How can I assist you today?"), - new FunctionCallContent( - "callId", - "MyCoolFunc", - new Dictionary - { - ["arg1"] = 42, - ["arg2"] = "str", - }) - ] - }; - - ChatResponse response = new(message) - { - ResponseId = "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", - ModelId = "gpt-4o-mini-2024-07-18", - CreatedAt = DateTimeOffset.FromUnixTimeSeconds(1_727_888_631), - FinishReason = ChatFinishReason.Stop, - Usage = new() - { - InputTokenCount = 8, - OutputTokenCount = 9, - TotalTokenCount = 17, - AdditionalCounts = new() - { - { "InputTokenDetails.AudioTokenCount", 1 }, - { "InputTokenDetails.CachedTokenCount", 13 }, - { "OutputTokenDetails.AudioTokenCount", 2 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, - } - }, - AdditionalProperties = new() - { - [nameof(ChatCompletion.SystemFingerprint)] = "fp_f85bea6784", - } - }; - - using MemoryStream stream = new(); - await OpenAISerializationHelpers.SerializeAsync(stream, response); - string result = Encoding.UTF8.GetString(stream.ToArray()); - - AssertJsonEqual(""" - { - "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", - "model": "gpt-4o-mini-2024-07-18", - "system_fingerprint": "fp_f85bea6784", - "usage": { - "completion_tokens": 9, - "prompt_tokens": 8, - "total_tokens": 17, - "completion_tokens_details": { - "reasoning_tokens": 90, - "audio_tokens": 2, - "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0 - }, - "prompt_tokens_details": { - "audio_tokens": 1, - "cached_tokens": 13 - } - }, - "object": "chat.completion", - "choices": [ - { - "finish_reason": "stop", - "index": 0, - "message": { - "refusal": null, - "tool_calls": [ - { - "id": "callId", - "function": { - "name": "MyCoolFunc", - "arguments": "{\r\n \u0022arg1\u0022: 42,\r\n \u0022arg2\u0022: \u0022str\u0022\r\n}" - }, - "type": "function" - } - ], - "annotations":[], - "role": "assistant", - "content": "Hello! How can I assist you today?" - }, - "logprobs": { - "content": [], - "refusal": [] - } - } - ], - "created": 1727888631 - } - """, result); - } - - [Fact] - public static async Task SerializeStreamingResponse() - { - static async IAsyncEnumerable CreateStreamingResponse() - { - for (int i = 0; i < 5; i++) - { - List contents = [new TextContent($"Streaming update {i}")]; - - if (i == 2) - { - FunctionCallContent fcc = new( - "callId", - "MyCoolFunc", - new Dictionary - { - ["arg1"] = 42, - ["arg2"] = "str", - }); - - contents.Add(fcc); - } - - if (i == 4) - { - UsageDetails usageDetails = new() - { - InputTokenCount = 8, - OutputTokenCount = 9, - TotalTokenCount = 17, - AdditionalCounts = new() - { - { "InputTokenDetails.AudioTokenCount", 1 }, - { "InputTokenDetails.CachedTokenCount", 13 }, - { "OutputTokenDetails.AudioTokenCount", 2 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, - } - }; - - contents.Add(new UsageContent(usageDetails)); - } - - yield return new ChatResponseUpdate - { - ResponseId = "chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl", - MessageId = "chatcmpl-DJ9a2DJw8892dsa8DJw8jdDsiwkai", // Won't appear in the output, as OpenAI has no representation of this - ModelId = "gpt-4o-mini-2024-07-18", - CreatedAt = DateTimeOffset.FromUnixTimeSeconds(1_727_888_631), - Role = ChatRole.Assistant, - Contents = contents, - FinishReason = i == 4 ? ChatFinishReason.Stop : null, - AdditionalProperties = new() - { - [nameof(ChatCompletion.SystemFingerprint)] = "fp_f85bea6784", - }, - }; - - await Task.Yield(); - } - } - - using MemoryStream stream = new(); - await OpenAISerializationHelpers.SerializeStreamingAsync(stream, CreateStreamingResponse()); - string result = Encoding.UTF8.GetString(stream.ToArray()); - - AssertSseEqual(""" - data: {"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","object":"chat.completion.chunk","id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","choices":[{"delta":{"tool_calls":[],"role":"assistant","content":"Streaming update 0"},"logprobs":{"content":[],"refusal":[]},"index":0}],"created":1727888631} - - data: {"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","object":"chat.completion.chunk","id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","choices":[{"delta":{"tool_calls":[],"role":"assistant","content":"Streaming update 1"},"logprobs":{"content":[],"refusal":[]},"index":0}],"created":1727888631} - - data: {"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","object":"chat.completion.chunk","id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"MyCoolFunc","arguments":"{\r\n \u0022arg1\u0022: 42,\r\n \u0022arg2\u0022: \u0022str\u0022\r\n}"},"type":"function","id":"callId"}],"role":"assistant","content":"Streaming update 2"},"logprobs":{"content":[],"refusal":[]},"index":0}],"created":1727888631} - - data: {"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","object":"chat.completion.chunk","id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","choices":[{"delta":{"tool_calls":[],"role":"assistant","content":"Streaming update 3"},"logprobs":{"content":[],"refusal":[]},"index":0}],"created":1727888631} - - data: {"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","object":"chat.completion.chunk","id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","choices":[{"delta":{"tool_calls":[],"role":"assistant","content":"Streaming update 4"},"logprobs":{"content":[],"refusal":[]},"finish_reason":"stop","index":0}],"created":1727888631,"usage":{"completion_tokens":9,"prompt_tokens":8,"total_tokens":17,"completion_tokens_details":{"reasoning_tokens":90,"audio_tokens":2,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":1,"cached_tokens":13}}} - - data: [DONE] - - - """, result); - } - - [Fact] - public static async Task SerializationHelpers_NullArguments_ThrowsArgumentNullException() - { - await Assert.ThrowsAsync(() => OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(null!)); - - await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeAsync(null!, new(new ChatMessage()))); - await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeAsync(new MemoryStream(), null!)); - - await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeStreamingAsync(null!, GetStreamingChatResponse())); - await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeStreamingAsync(new MemoryStream(), null!)); - - static async IAsyncEnumerable GetStreamingChatResponse() - { - yield return new ChatResponseUpdate(); - await Task.CompletedTask; - } - } - - [Fact] - public static async Task SerializationHelpers_HonorCancellationToken() - { - CancellationToken canceledToken = new(canceled: true); - MemoryStream stream = new("{}"u8.ToArray()); - - await Assert.ThrowsAsync(() => OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream, cancellationToken: canceledToken)); - await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeAsync(stream, new(new ChatMessage()), cancellationToken: canceledToken)); - await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeStreamingAsync(stream, GetStreamingChatResponse(), cancellationToken: canceledToken)); - - static async IAsyncEnumerable GetStreamingChatResponse() - { - yield return new ChatResponseUpdate(); - await Task.CompletedTask; - } - } - - [Fact] - public static async Task SerializationHelpers_HonorJsonSerializerOptions() - { - FunctionCallContent fcc = new( - "callId", - "MyCoolFunc", - new Dictionary - { - ["arg1"] = new SomeFunctionArgument(), - }); - - ChatResponse response = new(new ChatMessage - { - Role = ChatRole.Assistant, - Contents = [fcc], - }); - - using MemoryStream stream = new(); - - // Passing a JSO that contains a contract for the function argument results in successful serialization. - await OpenAISerializationHelpers.SerializeAsync(stream, response, options: JsonContextWithFunctionArgument.Default.Options); - stream.Position = 0; - - await OpenAISerializationHelpers.SerializeStreamingAsync(stream, GetStreamingResponse(), options: JsonContextWithFunctionArgument.Default.Options); - stream.Position = 0; - - // Passing a JSO without a contract for the function argument result in failed serialization. - await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeAsync(stream, response, options: JsonContextWithoutFunctionArgument.Default.Options)); - await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeStreamingAsync(stream, GetStreamingResponse(), options: JsonContextWithoutFunctionArgument.Default.Options)); - - async IAsyncEnumerable GetStreamingResponse() - { - await Task.Yield(); - yield return new ChatResponseUpdate - { - Contents = [fcc], - }; - } - } - - private class SomeFunctionArgument; - - [JsonSerializable(typeof(SomeFunctionArgument))] - [JsonSerializable(typeof(IDictionary))] - private partial class JsonContextWithFunctionArgument : JsonSerializerContext; - - [JsonSerializable(typeof(int))] - [JsonSerializable(typeof(IDictionary))] - private partial class JsonContextWithoutFunctionArgument : JsonSerializerContext; - - private static void AssertJsonEqual(string expected, string actual) - { - expected = NormalizeNewLines(expected); - actual = NormalizeNewLines(actual); - - var expectedNode = JsonNode.Parse(expected); - var actualNode = JsonNode.Parse(actual); - - if (!JsonNode.DeepEquals(expectedNode, actualNode)) - { - // JSON documents are not equal, assert on - // normal form strings for better reporting. - expected = expectedNode?.ToJsonString() ?? "null"; - actual = actualNode?.ToJsonString() ?? "null"; - Assert.Fail($"Expected:{Environment.NewLine}{expected}{Environment.NewLine}Actual:{Environment.NewLine}{actual}"); - } - } - - private static void AssertSseEqual(string expected, string actual) - { - Assert.Equal(expected.NormalizeNewLines(), actual.NormalizeNewLines()); - } - - private static string NormalizeNewLines(this string value) => - value.Replace("\r\n", "\n").Replace("\\r\\n", "\\n"); -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs index e0c53126e2b..de5a06ed171 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs @@ -21,8 +21,8 @@ }; var ghModelsClient = new OpenAIClient(credential, openAIOptions); -var chatClient = ghModelsClient.AsChatClient("gpt-4o-mini"); -var embeddingGenerator = ghModelsClient.AsEmbeddingGenerator("text-embedding-3-small"); +var chatClient = ghModelsClient.GetChatClient("gpt-4o-mini").AsIChatClient(); +var embeddingGenerator = ghModelsClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); var vectorStore = new JsonVectorStore(Path.Combine(AppContext.BaseDirectory, "vector-store"));