From 5a7b1a62d275fa0074de6106167d5178d7791ccb Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 3 Jul 2025 13:45:33 -0400 Subject: [PATCH] Update M.E.AI.OpenAI for latest OpenAI release --- eng/packages/General.props | 2 +- .../SpeechToText/SpeechToTextOptions.cs | 14 +- .../Microsoft.Extensions.AI.OpenAI.csproj | 6 +- .../OpenAIAssistantChatClient.cs | 2 - .../OpenAIChatClient.cs | 47 +++-- .../OpenAIClientExtensions.cs | 4 +- .../OpenAIRealtimeConversationClient.cs | 2 +- .../OpenAIResponseChatClient.cs | 48 +++-- .../OpenAISpeechToTextClient.cs | 199 ++++++++---------- ...icrosoft.Extensions.AI.OpenAI.Tests.csproj | 3 +- .../OpenAIAIFunctionConversionTests.cs | 14 +- ...enAIAssistantChatClientIntegrationTests.cs | 3 +- .../OpenAIAssistantChatClientTests.cs | 12 +- .../OpenAIChatClientTests.cs | 19 +- .../OpenAIEmbeddingGeneratorTests.cs | 11 +- .../OpenAIResponseClientTests.cs | 11 +- ...penAISpeechToTextClientIntegrationTests.cs | 2 +- .../OpenAISpeechToTextClientTests.cs | 14 +- 18 files changed, 186 insertions(+), 227 deletions(-) diff --git a/eng/packages/General.props b/eng/packages/General.props index fa2d51de886..aa9771bfb4a 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -16,7 +16,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs index 5ff0135cec7..8efbf510164 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs @@ -11,20 +11,20 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public class SpeechToTextOptions { + /// Gets or sets any additional properties associated with the options. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + /// Gets or sets the model ID for the speech to text. public string? ModelId { get; set; } /// Gets or sets the language of source speech. public string? SpeechLanguage { get; set; } - /// Gets or sets the language for the target generated text. - public string? TextLanguage { get; set; } - /// Gets or sets the sample rate of the speech input audio. public int? SpeechSampleRate { get; set; } - /// Gets or sets any additional properties associated with the options. - public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + /// Gets or sets the language for the target generated text. + public string? TextLanguage { get; set; } /// /// Gets or sets a callback responsible for creating the raw representation of the embedding generation options from an underlying implementation. @@ -51,11 +51,11 @@ public virtual SpeechToTextOptions Clone() { SpeechToTextOptions options = new() { + AdditionalProperties = AdditionalProperties?.Clone(), ModelId = ModelId, SpeechLanguage = SpeechLanguage, - TextLanguage = TextLanguage, SpeechSampleRate = SpeechSampleRate, - AdditionalProperties = AdditionalProperties?.Clone(), + TextLanguage = TextLanguage, }; return options; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj index 552d45f0fc6..a135ee011ea 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj @@ -1,4 +1,4 @@ - + Microsoft.Extensions.AI @@ -15,8 +15,8 @@ $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002;OPENAI002 - $(NoWarn);MEAI001 + $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002 + $(NoWarn);OPENAI001;OPENAI002;MEAI001 true true true diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs index 0b6c5f5122f..c1d59e30a68 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -28,7 +27,6 @@ namespace Microsoft.Extensions.AI; /// Represents an for an Azure.AI.Agents.Persistent . -[Experimental("OPENAI001")] internal sealed class OpenAIAssistantChatClient : IChatClient { /// The underlying . diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index c051550d493..3be0a1cc1ee 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -91,7 +91,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( // Make the call to OpenAI. var chatCompletionUpdates = _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); - return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, cancellationToken); + return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, openAIOptions, cancellationToken); } /// @@ -290,7 +290,8 @@ private static List ToOpenAIChatContent(IList private static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( IAsyncEnumerable updates, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + ChatCompletionOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) { Dictionary? functionCallInfos = null; ChatRole? streamedRole = null; @@ -334,6 +335,14 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha } } + if (update.OutputAudioUpdate is { } audioUpdate) + { + responseUpdate.Contents.Add(new DataContent(audioUpdate.AudioBytesUpdate.ToMemory(), GetOutputAudioMimeType(options)) + { + RawRepresentation = audioUpdate, + }); + } + // Transfer over refusal updates. if (update.RefusalUpdate is not null) { @@ -363,8 +372,10 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha // Transfer over usage updates. if (update.Usage is ChatTokenUsage tokenUsage) { - var usageDetails = FromOpenAIUsage(tokenUsage); - responseUpdate.Contents.Add(new UsageContent(usageDetails)); + responseUpdate.Contents.Add(new UsageContent(FromOpenAIUsage(tokenUsage)) + { + RawRepresentation = tokenUsage, + }); } // Now yield the item. @@ -408,6 +419,17 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha } } + private static string GetOutputAudioMimeType(ChatCompletionOptions? options) => + options?.AudioOptions?.OutputAudioFormat.ToString()?.ToLowerInvariant() switch + { + "opus" => "audio/opus", + "aac" => "audio/aac", + "flac" => "audio/flac", + "wav" => "audio/wav", + "pcm" => "audio/pcm", + "mp3" or _ => "audio/mpeg", + }; + private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatOptions? options, ChatCompletionOptions chatCompletionOptions) { _ = Throw.IfNull(openAICompletion); @@ -432,19 +454,10 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple // Output audio is handled separately from message content parts. if (openAICompletion.OutputAudio is ChatOutputAudio audio) { - string mimeType = chatCompletionOptions?.AudioOptions?.OutputAudioFormat.ToString()?.ToLowerInvariant() switch + returnMessage.Contents.Add(new DataContent(audio.AudioBytes.ToMemory(), GetOutputAudioMimeType(chatCompletionOptions)) { - "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); - - returnMessage.Contents.Add(dc); + RawRepresentation = audio, + }); } // Also manufacture function calling content items from any tool calls in the response. @@ -505,9 +518,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.PresencePenalty ??= options.PresencePenalty; result.Temperature ??= options.Temperature; result.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. result.Seed ??= options.Seed; -#pragma warning restore OPENAI001 if (options.StopSequences is { Count: > 0 } stopSequences) { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index b20769c0dc4..dccddf3038e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -14,7 +14,7 @@ using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Embeddings; -using OpenAI.RealtimeConversation; +using OpenAI.Realtime; using OpenAI.Responses; #pragma warning disable S103 // Lines should not be too long @@ -134,7 +134,6 @@ public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient /// is . /// is . /// is empty or composed entirely of whitespace. - [Experimental("OPENAI001")] // AssistantClient itself is experimental with this ID public static IChatClient AsIChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) => new OpenAIAssistantChatClient(assistantClient, assistantId, threadId); @@ -165,7 +164,6 @@ public static ChatTool AsOpenAIChatTool(this AIFunction function) => /// The function to convert. /// An OpenAI representing . /// is . - [Experimental("OPENAI001")] // AssistantClient itself is experimental with this ID public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) => OpenAIAssistantChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs index abfebd99f34..7c944ac5edb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using OpenAI.RealtimeConversation; +using OpenAI.Realtime; namespace Microsoft.Extensions.AI; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index 46019166719..6aee4bc77e4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -117,6 +117,13 @@ public async Task GetResponseAsync( ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); break; + case ReasoningResponseItem reasoningItem when reasoningItem.GetSummaryText() is string summary && !string.IsNullOrWhiteSpace(summary): + message.Contents.Add(new TextReasoningContent(summary) + { + RawRepresentation = reasoningItem + }); + break; + case FunctionCallResponseItem functionCall: response.FinishReason ??= ChatFinishReason.ToolCalls; var fcc = FunctionCallContent.CreateFromParsedArguments( @@ -139,7 +146,7 @@ public async Task GetResponseAsync( if (openAIResponse.Error is { } error) { - message.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code }); + message.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); } } @@ -367,10 +374,11 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt // Handle strongly-typed properties. result.MaxOutputTokenCount ??= options.MaxOutputTokens; + result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; result.PreviousResponseId ??= options.ConversationId; - result.TopP ??= options.TopP; result.Temperature ??= options.Temperature; - result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; + result.TopP ??= options.TopP; + if (options.Instructions is { } instructions) { result.Instructions = string.IsNullOrEmpty(result.Instructions) ? @@ -386,22 +394,21 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt switch (tool) { case AIFunction aiFunction: - ResponseTool rtool = ToResponseTool(aiFunction, options); - result.Tools.Add(rtool); + result.Tools.Add(ToResponseTool(aiFunction, options)); break; case HostedWebSearchTool: - WebSearchToolLocation? location = null; - if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchToolLocation), out object? objLocation)) + WebSearchUserLocation? location = null; + if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchUserLocation), out object? objLocation)) { - location = objLocation as WebSearchToolLocation; + location = objLocation as WebSearchUserLocation; } - WebSearchToolContextSize? size = null; - if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchToolContextSize), out object? objSize) && - objSize is WebSearchToolContextSize) + WebSearchContextSize? size = null; + if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchContextSize), out object? objSize) && + objSize is WebSearchContextSize) { - size = (WebSearchToolContextSize)objSize; + size = (WebSearchContextSize)objSize; } result.Tools.Add(ResponseTool.CreateWebSearchTool(location, size)); @@ -522,6 +529,10 @@ private static IEnumerable ToOpenAIResponseItems( yield return ResponseItem.CreateAssistantMessageItem(textContent.Text); break; + case TextReasoningContent reasoningContent: + yield return ResponseItem.CreateReasoningItem(reasoningContent.Text); + break; + case FunctionCallContent callContent: yield return ResponseItem.CreateFunctionCallItem( callContent.CallId, @@ -555,12 +566,16 @@ private static IEnumerable ToOpenAIResponseItems( TotalTokenCount = usage.TotalTokenCount, }; - if (usage.OutputTokenDetails is { } outputDetails) + if (usage.InputTokenDetails is { } inputDetails) { ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); + } - const string OutputDetails = nameof(usage.OutputTokenDetails); - ud.AdditionalCounts.Add($"{OutputDetails}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); + if (usage.OutputTokenDetails is { } outputDetails) + { + ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); } } @@ -624,8 +639,7 @@ private static List ToOpenAIResponsesContent(IListDefault OpenAI endpoint. - private static readonly Uri _defaultOpenAIEndpoint = new("https://api.openai.com/v1"); + /// Filename to use when audio lacks a name. + /// This information internally is required but is only being used to create a header name in the multipart request. + private const string Filename = "audio.mp3"; /// Metadata about the client. private readonly SpeechToTextClientMetadata _metadata; @@ -45,7 +46,7 @@ public OpenAISpeechToTextClient(AudioClient audioClient) // 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(AudioClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(audioClient) as Uri ?? _defaultOpenAIEndpoint; + ?.GetValue(audioClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint; string? model = typeof(AudioClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(audioClient) as string; @@ -65,20 +66,6 @@ public OpenAISpeechToTextClient(AudioClient audioClient) null; } - /// - public async IAsyncEnumerable GetStreamingTextAsync( - Stream audioSpeechStream, SpeechToTextOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(audioSpeechStream); - - var speechResponse = await GetTextAsync(audioSpeechStream, options, cancellationToken).ConfigureAwait(false); - - foreach (var update in speechResponse.ToSpeechToTextResponseUpdates()) - { - yield return update; - } - } - /// public async Task GetTextAsync( Stream audioSpeechStream, SpeechToTextOptions? options = null, CancellationToken cancellationToken = default) @@ -87,140 +74,126 @@ public async Task GetTextAsync( SpeechToTextResponse response = new(); - // A translation is triggered when the target text language is specified and the source language is not provided or different. - static bool IsTranslationRequest(SpeechToTextOptions? options) - => options is not null && options.TextLanguage is not null - && (options.SpeechLanguage is null || options.SpeechLanguage != options.TextLanguage); + string filename = audioSpeechStream is FileStream fileStream ? + Path.GetFileName(fileStream.Name) : // Use the file name if we can get one from the stream. + Filename; // Otherwise, use a default name; this is only used to create a header name in the multipart request. if (IsTranslationRequest(options)) { - _ = Throw.IfNull(options); + var translation = (await _audioClient.TranslateAudioAsync(audioSpeechStream, filename, ToOpenAITranslationOptions(options), cancellationToken).ConfigureAwait(false)).Value; - var openAIOptions = ToOpenAITranslationOptions(options); - AudioTranslation translationResult; + response.Contents = [new TextContent(translation.Text)]; + response.RawRepresentation = translation; -#if NET - await using (audioSpeechStream.ConfigureAwait(false)) -#else - using (audioSpeechStream) -#endif + int segmentCount = translation.Segments.Count; + if (segmentCount > 0) { - translationResult = (await _audioClient.TranslateAudioAsync( - audioSpeechStream, - "file.wav", // this information internally is required but is only being used to create a header name in the multipart request. - openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + response.StartTime = translation.Segments[0].StartTime; + response.EndTime = translation.Segments[segmentCount - 1].EndTime; } - - UpdateResponseFromOpenAIAudioTranslation(response, translationResult); } else { - var openAIOptions = ToOpenAITranscriptionOptions(options); + var transcription = (await _audioClient.TranscribeAudioAsync(audioSpeechStream, filename, ToOpenAITranscriptionOptions(options), cancellationToken).ConfigureAwait(false)).Value; - // Transcription request - AudioTranscription transcriptionResult; + response.Contents = [new TextContent(transcription.Text)]; + response.RawRepresentation = transcription; -#if NET - await using (audioSpeechStream.ConfigureAwait(false)) -#else - using (audioSpeechStream) -#endif + int segmentCount = transcription.Segments.Count; + if (segmentCount > 0) { - transcriptionResult = (await _audioClient.TranscribeAudioAsync( - audioSpeechStream, - "file.wav", // this information internally is required but is only being used to create a header name in the multipart request. - openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + response.StartTime = transcription.Segments[0].StartTime; + response.EndTime = transcription.Segments[segmentCount - 1].EndTime; + } + else + { + int wordCount = transcription.Words.Count; + if (wordCount > 0) + { + response.StartTime = transcription.Words[0].StartTime; + response.EndTime = transcription.Words[wordCount - 1].EndTime; + } } - - UpdateResponseFromOpenAIAudioTranscription(response, transcriptionResult); } return response; } /// - void IDisposable.Dispose() - { - // Nothing to dispose. Implementation required for the IAudioTranscriptionClient interface. - } - - /// Updates a from an OpenAI . - /// The response to update. - /// The OpenAI audio transcription. - private static void UpdateResponseFromOpenAIAudioTranscription(SpeechToTextResponse response, AudioTranscription audioTranscription) + public async IAsyncEnumerable GetStreamingTextAsync( + Stream audioSpeechStream, SpeechToTextOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - _ = Throw.IfNull(audioTranscription); + _ = Throw.IfNull(audioSpeechStream); - var segmentCount = audioTranscription.Segments.Count; - var wordCount = audioTranscription.Words.Count; + string filename = audioSpeechStream is FileStream fileStream ? + Path.GetFileName(fileStream.Name) : // Use the file name if we can get one from the stream. + Filename; // Otherwise, use a default name; this is only used to create a header name in the multipart request. - TimeSpan? endTime = null; - TimeSpan? startTime = null; - if (segmentCount > 0) + if (IsTranslationRequest(options)) { - endTime = audioTranscription.Segments[segmentCount - 1].EndTime; - startTime = audioTranscription.Segments[0].StartTime; + foreach (var update in (await GetTextAsync(audioSpeechStream, options, cancellationToken).ConfigureAwait(false)).ToSpeechToTextResponseUpdates()) + { + yield return update; + } } - else if (wordCount > 0) + else { - endTime = audioTranscription.Words[wordCount - 1].EndTime; - startTime = audioTranscription.Words[0].StartTime; + await foreach (var update in _audioClient.TranscribeAudioStreamingAsync( + audioSpeechStream, + filename, + ToOpenAITranscriptionOptions(options), + cancellationToken).ConfigureAwait(false)) + { + SpeechToTextResponseUpdate result = new() + { + ModelId = options?.ModelId, + RawRepresentation = update, + }; + + switch (update) + { + case StreamingAudioTranscriptionTextDeltaUpdate deltaUpdate: + result.Kind = SpeechToTextResponseUpdateKind.TextUpdated; + result.Contents = [new TextContent(deltaUpdate.Delta)]; + break; + + case StreamingAudioTranscriptionTextDoneUpdate doneUpdate: + result.Kind = SpeechToTextResponseUpdateKind.SessionClose; + break; + } + + yield return result; + } } + } - // Update the response - response.RawRepresentation = audioTranscription; - response.Contents = [new TextContent(audioTranscription.Text)]; - response.StartTime = startTime; - response.EndTime = endTime; - response.AdditionalProperties = new AdditionalPropertiesDictionary - { - [nameof(audioTranscription.Language)] = audioTranscription.Language, - [nameof(audioTranscription.Duration)] = audioTranscription.Duration - }; + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IAudioTranscriptionClient interface. } - /// Converts an extensions options instance to an OpenAI options instance. + // A translation is triggered when the target text language is specified and the source language is not provided or different. + private static bool IsTranslationRequest(SpeechToTextOptions? options) => + options is not null && + options.TextLanguage is not null && + (options.SpeechLanguage is null || options.SpeechLanguage != options.TextLanguage); + + /// Converts an extensions options instance to an OpenAI transcription options instance. private AudioTranscriptionOptions ToOpenAITranscriptionOptions(SpeechToTextOptions? options) { - if (options?.RawRepresentationFactory?.Invoke(this) is not AudioTranscriptionOptions result) - { - result = new AudioTranscriptionOptions(); - } + AudioTranscriptionOptions result = options?.RawRepresentationFactory?.Invoke(this) as AudioTranscriptionOptions ?? new(); result.Language ??= options?.SpeechLanguage; + return result; } - /// Updates a from an OpenAI . - /// The response to update. - /// The OpenAI audio translation. - private static void UpdateResponseFromOpenAIAudioTranslation(SpeechToTextResponse response, AudioTranslation audioTranslation) + /// Converts an extensions options instance to an OpenAI translation options instance. + private AudioTranslationOptions ToOpenAITranslationOptions(SpeechToTextOptions? options) { - _ = Throw.IfNull(audioTranslation); - - var segmentCount = audioTranslation.Segments.Count; - - TimeSpan? endTime = null; - TimeSpan? startTime = null; - if (segmentCount > 0) - { - endTime = audioTranslation.Segments[segmentCount - 1].EndTime; - startTime = audioTranslation.Segments[0].StartTime; - } + AudioTranslationOptions result = options?.RawRepresentationFactory?.Invoke(this) as AudioTranslationOptions ?? new(); - // Update the response - response.RawRepresentation = audioTranslation; - response.Contents = [new TextContent(audioTranslation.Text)]; - response.StartTime = startTime; - response.EndTime = endTime; - response.AdditionalProperties = new AdditionalPropertiesDictionary - { - [nameof(audioTranslation.Language)] = audioTranslation.Language, - [nameof(audioTranslation.Duration)] = audioTranslation.Duration - }; + return result; } - - /// Converts an extensions options instance to an OpenAI options instance. - private AudioTranslationOptions ToOpenAITranslationOptions(SpeechToTextOptions? options) - => options?.RawRepresentationFactory?.Invoke(this) as AudioTranslationOptions ?? new AudioTranslationOptions(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj index 60b6f8c84ab..536c250cb47 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj @@ -2,7 +2,8 @@ Microsoft.Extensions.AI Unit tests for Microsoft.Extensions.AI.OpenAI - $(NoWarn);OPENAI002;MEAI001;S104 + $(NoWarn);S104 + $(NoWarn);OPENAI001;MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs index f2f0c9d8a3f..ce458473c59 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs @@ -6,12 +6,10 @@ using System.Text.Json; using OpenAI.Assistants; using OpenAI.Chat; -using OpenAI.RealtimeConversation; +using OpenAI.Realtime; using OpenAI.Responses; using Xunit; -#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. - namespace Microsoft.Extensions.AI; public class OpenAIAIFunctionConversionTests @@ -24,7 +22,7 @@ public class OpenAIAIFunctionConversionTests [Fact] public void AsOpenAIChatTool_ProducesValidInstance() { - ChatTool tool = _testFunction.AsOpenAIChatTool(); + var tool = _testFunction.AsOpenAIChatTool(); Assert.NotNull(tool); Assert.Equal("test_function", tool.FunctionName); @@ -35,7 +33,7 @@ public void AsOpenAIChatTool_ProducesValidInstance() [Fact] public void AsOpenAIResponseTool_ProducesValidInstance() { - ResponseTool tool = _testFunction.AsOpenAIResponseTool(); + var tool = _testFunction.AsOpenAIResponseTool(); Assert.NotNull(tool); } @@ -43,7 +41,7 @@ public void AsOpenAIResponseTool_ProducesValidInstance() [Fact] public void AsOpenAIConversationFunctionTool_ProducesValidInstance() { - ConversationFunctionTool tool = _testFunction.AsOpenAIConversationFunctionTool(); + var tool = _testFunction.AsOpenAIConversationFunctionTool(); Assert.NotNull(tool); Assert.Equal("test_function", tool.Name); @@ -54,7 +52,7 @@ public void AsOpenAIConversationFunctionTool_ProducesValidInstance() [Fact] public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() { - FunctionToolDefinition tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); + var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); Assert.NotNull(tool); Assert.Equal("test_function", tool.FunctionName); @@ -62,7 +60,7 @@ public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() ValidateSchemaParameters(tool.Parameters); } - /// Helper method to validate function parameters match our schema + /// Helper method to validate function parameters match our schema. private static void ValidateSchemaParameters(BinaryData parameters) { Assert.NotNull(parameters); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs index e616d5fb87b..90bcf9f2632 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs @@ -1,7 +1,6 @@ // 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 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 CA1822 // Mark members as static #pragma warning disable CA2000 // Dispose objects before losing scope #pragma warning disable S1135 // Track uses of "TODO" tags @@ -62,7 +61,7 @@ public async Task DeleteAllThreads() client.DefaultRequestHeaders.Add("openai-organization", "org-ENTERYOURORGID"); client.DefaultRequestHeaders.Add("openai-project", "proj_ENTERYOURPROJECTID"); - AssistantClient ac = new AssistantClient(Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!); + AssistantClient ac = new(Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!); while (true) { string listing = await client.GetStringAsync("https://api.openai.com/v1/threads?limit=100"); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs index 6d3a02a08ec..3b084b5ec8a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs @@ -3,7 +3,6 @@ using System; using System.ClientModel; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -11,7 +10,6 @@ using Xunit; #pragma warning disable S103 // Lines should not be too long -#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. namespace Microsoft.Extensions.AI; @@ -24,16 +22,12 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient(null!)); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient[] clients = [ diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index edb5d9fab07..d06d8f520be 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -11,7 +11,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -30,17 +29,13 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("chatClient", () => ((ChatClient)null!).AsIChatClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.GetChatClient(model).AsIChatClient(); var metadata = chatClient.GetService(); @@ -398,9 +393,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TopP = 0.5f, PresencePenalty = 0.5f, Temperature = 0.5f, -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, -#pragma warning restore OPENAI001 ToolChoice = ChatToolChoice.CreateAutoChoice(), ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; @@ -477,9 +470,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TopP = 0.5f, PresencePenalty = 0.5f, Temperature = 0.5f, -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, -#pragma warning restore OPENAI001 ToolChoice = ChatToolChoice.CreateAutoChoice(), ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; @@ -561,9 +552,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr Assert.Null(openAIOptions.TopP); Assert.Null(openAIOptions.PresencePenalty); Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 Assert.Empty(openAIOptions.StopSequences); Assert.Empty(openAIOptions.Tools); Assert.Null(openAIOptions.ToolChoice); @@ -637,9 +626,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream Assert.Null(openAIOptions.TopP); Assert.Null(openAIOptions.PresencePenalty); Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 Assert.Empty(openAIOptions.StopSequences); Assert.Empty(openAIOptions.Tools); Assert.Null(openAIOptions.ToolChoice); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index 9d8a1219ea7..43112fa88e3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -6,7 +6,6 @@ using System.ClientModel.Primitives; using System.Net.Http; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -25,17 +24,13 @@ public void AsIEmbeddingGenerator_InvalidArgs_Throws() Assert.Throws("embeddingClient", () => ((EmbeddingClient)null!).AsIEmbeddingGenerator()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IEmbeddingGenerator> embeddingGenerator = client.GetEmbeddingClient(model).AsIEmbeddingGenerator(); var metadata = embeddingGenerator.GetService(); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 28125e462b7..b98eb89197f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -10,7 +10,6 @@ using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -29,17 +28,13 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("responseClient", () => ((OpenAIResponseClient)null!).AsIChatClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.GetOpenAIResponseClient(model).AsIChatClient(); var metadata = chatClient.GetService(); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs index c80b37c865e..bb721806ff6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs @@ -7,6 +7,6 @@ public class OpenAISpeechToTextClientIntegrationTests : SpeechToTextClientIntegr { protected override ISpeechToTextClient? CreateClient() => IntegrationTestHelpers.GetOpenAIClient()? - .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:AudioTranscriptionModel"] ?? "whisper-1") + .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:AudioTranscriptionModel"] ?? "gpt-4o-mini-transcribe") .AsISpeechToTextClient(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs index c92d9627968..1252a20741b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs @@ -8,7 +8,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Logging; using OpenAI; using OpenAI.Audio; @@ -26,17 +25,13 @@ public void AsISpeechToTextClient_InvalidArgs_Throws() Assert.Throws("audioClient", () => ((AudioClient)null!).AsISpeechToTextClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsISpeechToTextClient_AudioClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsISpeechToTextClient_AudioClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); ISpeechToTextClient speechToTextClient = client.GetAudioClient(model).AsISpeechToTextClient(); var metadata = speechToTextClient.GetService(); @@ -148,7 +143,8 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu string input = $$""" { "model": "whisper-1", - "language": "{{speechLanguage}}" + "language": "{{speechLanguage}}", + "stream":true } """;