From de42e0d0a16bb0ab71c727e2b42948ef549fb64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Wed, 14 May 2025 23:31:31 -0500 Subject: [PATCH 1/3] Port Microsoft.Extensions.AI.AzureAIInference to Azure.AI.Inference --- eng/Packages.Data.props | 1 + .../src/Azure.AI.Inference.csproj | 1 + .../Customized/AzureAIInferenceChatClient.cs | 566 ++++++++++++++++++ .../AzureAIInferenceEmbeddingGenerator.cs | 178 ++++++ .../Customized/AzureAIInferenceExtensions.cs | 38 ++ ...AzureAIInferenceImageEmbeddingGenerator.cs | 131 ++++ .../src/Customized/ChatCompletionsClient.cs | 2 + .../src/Customized/EmbeddingsClient.cs | 2 + .../src/Customized/ImageEmbeddingsClient.cs | 2 + 9 files changed, 921 insertions(+) create mode 100644 sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceChatClient.cs create mode 100644 sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceEmbeddingGenerator.cs create mode 100644 sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceExtensions.cs create mode 100644 sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceImageEmbeddingGenerator.cs diff --git a/eng/Packages.Data.props b/eng/Packages.Data.props index ef7e4490c317..801f30b967fd 100644 --- a/eng/Packages.Data.props +++ b/eng/Packages.Data.props @@ -104,6 +104,7 @@ + diff --git a/sdk/ai/Azure.AI.Inference/src/Azure.AI.Inference.csproj b/sdk/ai/Azure.AI.Inference/src/Azure.AI.Inference.csproj index e98241911ddc..1c1ded086253 100644 --- a/sdk/ai/Azure.AI.Inference/src/Azure.AI.Inference.csproj +++ b/sdk/ai/Azure.AI.Inference/src/Azure.AI.Inference.csproj @@ -18,6 +18,7 @@ + diff --git a/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceChatClient.cs b/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceChatClient.cs new file mode 100644 index 000000000000..b85b00273d85 --- /dev/null +++ b/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceChatClient.cs @@ -0,0 +1,566 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Inference; + +namespace Microsoft.Extensions.AI; + +/// Represents an for an Azure AI Inference . +internal sealed partial class AzureAIInferenceChatClient : IChatClient +{ + /// Gets the JSON schema transform cache conforming to OpenAI restrictions per https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. + private static AIJsonSchemaTransformCache SchemaTransformCache { get; } = new(new() + { + RequireAllProperties = true, + DisallowAdditionalProperties = true, + ConvertBooleanSchemas = true, + MoveDefaultKeywordToDescription = true, + }); + + /// Metadata about the client. + private readonly ChatClientMetadata _metadata; + + /// The underlying . + private readonly ChatCompletionsClient _chatCompletionsClient; + + /// 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 , it can be provided per request via . + /// is . + /// is empty or composed entirely of whitespace. + public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, string? defaultModelId = null) + { + Argument.AssertNotNull(chatCompletionsClient, nameof(chatCompletionsClient)); + + if (defaultModelId is not null) + { + Argument.AssertNotNullOrWhiteSpace(defaultModelId, nameof(defaultModelId)); + } + + _chatCompletionsClient = chatCompletionsClient; + _metadata = new ChatClientMetadata("az.ai.inference", chatCompletionsClient.Endpoint, defaultModelId); + } + + /// + object? IChatClient.GetService(Type serviceType, object? serviceKey) + { + Argument.AssertNotNull(serviceKey, nameof(serviceKey)); + + return + serviceKey is not null ? null : + serviceType == typeof(ChatCompletionsClient) ? _chatCompletionsClient : + serviceType == typeof(ChatClientMetadata) ? _metadata : + serviceType.IsInstanceOfType(this) ? this : + null; + } + + /// + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(messages, nameof(messages)); + + // Make the call. + ChatCompletions response = (await _chatCompletionsClient.CompleteAsync( + ToAzureAIOptions(messages, options), + cancellationToken: cancellationToken).ConfigureAwait(false)).Value; + + // Create the return message. + ChatMessage message = new(ToChatRole(response.Role), response.Content) + { + MessageId = response.Id, // There is no per-message ID, but there's only one message per response, so use the response ID + RawRepresentation = response, + }; + + if (response.ToolCalls is { Count: > 0 } toolCalls) + { + foreach (var toolCall in toolCalls) + { + if (toolCall is ChatCompletionsToolCall ftc && !string.IsNullOrWhiteSpace(ftc.Name)) + { + FunctionCallContent callContent = ParseCallContentFromJsonString(ftc.Arguments, toolCall.Id, ftc.Name); + callContent.RawRepresentation = toolCall; + + message.Contents.Add(callContent); + } + } + } + + UsageDetails? usage = null; + if (response.Usage is CompletionsUsage completionsUsage) + { + usage = new() + { + InputTokenCount = completionsUsage.PromptTokens, + OutputTokenCount = completionsUsage.CompletionTokens, + TotalTokenCount = completionsUsage.TotalTokens, + }; + } + + // Wrap the content in a ChatResponse to return. + return new ChatResponse(message) + { + CreatedAt = response.Created, + ModelId = response.Model, + FinishReason = ToFinishReason(response.FinishReason), + RawRepresentation = response, + ResponseId = response.Id, + Usage = usage, + }; + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(messages, nameof(messages)); + + Dictionary? functionCallInfos = null; + ChatRole? streamedRole = default; + ChatFinishReason? finishReason = default; + string? responseId = null; + DateTimeOffset? createdAt = null; + string? modelId = null; + string lastCallId = string.Empty; + + // Process each update as it arrives + var updates = await _chatCompletionsClient.CompleteStreamingAsync(ToAzureAIOptions(messages, options), cancellationToken).ConfigureAwait(false); + await foreach (StreamingChatCompletionsUpdate chatCompletionUpdate in updates.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 ??= chatCompletionUpdate.Role is global::Azure.AI.Inference.ChatRole role ? ToChatRole(role) : null; + finishReason ??= chatCompletionUpdate.FinishReason is CompletionsFinishReason reason ? ToFinishReason(reason) : null; + responseId ??= chatCompletionUpdate.Id; // While it's unclear from the name, this Id is documented to be the response ID, not the chunk ID + createdAt ??= chatCompletionUpdate.Created; + modelId ??= chatCompletionUpdate.Model; + + // Create the response content object. + ChatResponseUpdate responseUpdate = new() + { + CreatedAt = chatCompletionUpdate.Created, + FinishReason = finishReason, + ModelId = modelId, + RawRepresentation = chatCompletionUpdate, + ResponseId = responseId, + MessageId = responseId, // There is no per-message ID, but there's only one message per response, so use the response ID + Role = streamedRole, + }; + + // Transfer over content update items. + if (chatCompletionUpdate.ContentUpdate is string update) + { + responseUpdate.Contents.Add(new TextContent(update)); + } + + // Transfer over tool call updates. + if (chatCompletionUpdate.ToolCallUpdate is { } toolCallUpdate) + { + // TODO https://github.com/Azure/azure-sdk-for-net/issues/46830: Azure.AI.Inference + // has removed the Index property from ToolCallUpdate. It's now impossible via the + // exposed APIs to correctly handle multiple parallel tool calls, as the CallId is + // often null for anything other than the first update for a given call, and Index + // isn't available to correlate which updates are for which call. This is a temporary + // workaround to at least make a single tool call work and also make work multiple + // tool calls when their updates aren't interleaved. + if (toolCallUpdate.Id is not null) + { + lastCallId = toolCallUpdate.Id; + } + + functionCallInfos ??= []; + if (!functionCallInfos.TryGetValue(lastCallId, out FunctionCallInfo? existing)) + { + functionCallInfos[lastCallId] = existing = new(); + } + + existing.Name ??= toolCallUpdate.Function.Name; + if (toolCallUpdate.Function.Arguments is { } arguments) + { + _ = (existing.Arguments ??= new()).Append(arguments); + } + } + + if (chatCompletionUpdate.Usage is { } usage) + { + responseUpdate.Contents.Add(new UsageContent(new() + { + InputTokenCount = usage.PromptTokens, + OutputTokenCount = usage.CompletionTokens, + TotalTokenCount = usage.TotalTokens, + })); + } + + // 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) + { + var responseUpdate = new ChatResponseUpdate + { + CreatedAt = createdAt, + FinishReason = finishReason, + ModelId = modelId, + ResponseId = responseId, + MessageId = responseId, // There is no per-message ID, but there's only one message per response, so use the response ID + Role = streamedRole, + }; + + foreach (var entry in functionCallInfos) + { + FunctionCallInfo fci = entry.Value; + if (!string.IsNullOrWhiteSpace(fci.Name)) + { + FunctionCallContent callContent = ParseCallContentFromJsonString( + fci.Arguments?.ToString() ?? string.Empty, + entry.Key, + fci.Name!); + responseUpdate.Contents.Add(callContent); + } + } + + yield return responseUpdate; + } + } + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IChatClient interface. + } + + /// 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? Name; + public StringBuilder? Arguments; + } + + /// Converts an AzureAI role to an Extensions role. + private static ChatRole ToChatRole(global::Azure.AI.Inference.ChatRole role) => + role.Equals(global::Azure.AI.Inference.ChatRole.System) ? ChatRole.System : + role.Equals(global::Azure.AI.Inference.ChatRole.User) ? ChatRole.User : + role.Equals(global::Azure.AI.Inference.ChatRole.Assistant) ? ChatRole.Assistant : + role.Equals(global::Azure.AI.Inference.ChatRole.Tool) ? ChatRole.Tool : + role.Equals(global::Azure.AI.Inference.ChatRole.Developer) ? ChatRoleDeveloper : + new ChatRole(role.ToString()); + + /// Converts an AzureAI finish reason to an Extensions finish reason. + private static ChatFinishReason? ToFinishReason(CompletionsFinishReason? finishReason) => + finishReason?.ToString() is not string s ? null : + finishReason == CompletionsFinishReason.Stopped ? ChatFinishReason.Stop : + finishReason == CompletionsFinishReason.TokenLimitReached ? ChatFinishReason.Length : + finishReason == CompletionsFinishReason.ContentFiltered ? ChatFinishReason.ContentFilter : + finishReason == CompletionsFinishReason.ToolCalls ? ChatFinishReason.ToolCalls : + new(s); + + private ChatCompletionsOptions CreateAzureAIOptions(IEnumerable chatContents, ChatOptions? options) => + new(ToAzureAIInferenceChatMessages(chatContents)) + { + Model = options?.ModelId ?? _metadata.DefaultModelId ?? + throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.") + }; + + /// Converts an extensions options instance to an Azure.AI.Inference options instance. + private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatContents, ChatOptions? options) + { + if (options is null) + { + return CreateAzureAIOptions(chatContents, options); + } + + if (options.RawRepresentationFactory?.Invoke(this) is ChatCompletionsOptions result) + { + result.Messages = ToAzureAIInferenceChatMessages(chatContents).ToList(); + result.Model ??= options.ModelId ?? _metadata.DefaultModelId ?? + throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options."); + } + else + { + result = CreateAzureAIOptions(chatContents, options); + } + + result.FrequencyPenalty ??= options.FrequencyPenalty; + result.MaxTokens ??= options.MaxOutputTokens; + result.NucleusSamplingFactor ??= options.TopP; + result.PresencePenalty ??= options.PresencePenalty; + result.Temperature ??= options.Temperature; + result.Seed ??= options.Seed; + + if (options.StopSequences is { Count: > 0 } stopSequences) + { + foreach (string stopSequence in stopSequences) + { + result.StopSequences.Add(stopSequence); + } + } + + // This property is strongly typed on ChatOptions but not on ChatCompletionsOptions. + if (options.TopK is int topK && !result.AdditionalProperties.ContainsKey("top_k")) + { + result.AdditionalProperties["top_k"] = new BinaryData(JsonSerializer.SerializeToUtf8Bytes(topK, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(int)))); + } + + if (options.AdditionalProperties is { } props) + { + foreach (var prop in props) + { + byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + result.AdditionalProperties[prop.Key] = new BinaryData(data); + } + } + + if (options.Tools is { Count: > 0 } tools) + { + foreach (AITool tool in tools) + { + if (tool is AIFunction af) + { + result.Tools.Add(ToAzureAIChatTool(af)); + } + } + + if (result.ToolChoice is null && result.Tools.Count > 0) + { + switch (options.ToolMode) + { + case NoneChatToolMode: + result.ToolChoice = ChatCompletionsToolChoice.None; + break; + + case AutoChatToolMode: + case null: + result.ToolChoice = ChatCompletionsToolChoice.Auto; + break; + + case RequiredChatToolMode required: + result.ToolChoice = required.RequiredFunctionName is null ? + ChatCompletionsToolChoice.Required : + new ChatCompletionsToolChoice(new FunctionDefinition(required.RequiredFunctionName)); + break; + } + } + } + + if (result.ResponseFormat is null) + { + if (options.ResponseFormat is ChatResponseFormatText) + { + result.ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat(); + } + else if (options.ResponseFormat is ChatResponseFormatJson json) + { + if (SchemaTransformCache.GetOrCreateTransformedSchema(json) is { } schema) + { + var tool = JsonSerializer.Deserialize(schema, ChatClientJsonContext.Default.AzureAIChatToolJson)!; + result.ResponseFormat = ChatCompletionsResponseFormat.CreateJsonFormat( + json.SchemaName ?? "json_schema", + new Dictionary + { + ["type"] = _objectString, + ["properties"] = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool.Properties, ChatClientJsonContext.Default.DictionaryStringJsonElement)), + ["required"] = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool.Required, ChatClientJsonContext.Default.ListString)), + ["additionalProperties"] = _falseString, + }, + json.SchemaDescription); + } + else + { + result.ResponseFormat = ChatCompletionsResponseFormat.CreateJsonFormat(); + } + } + } + + return result; + } + + /// Cached for "object". + private static readonly BinaryData _objectString = BinaryData.FromString("\"object\""); + + /// Cached for "false". + private static readonly BinaryData _falseString = BinaryData.FromString("false"); + + /// Converts an Extensions function to an AzureAI chat tool. + private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunction) + { + // Map to an intermediate model so that redundant properties are skipped. + var tool = JsonSerializer.Deserialize(SchemaTransformCache.GetOrCreateTransformedSchema(aiFunction), ChatClientJsonContext.Default.AzureAIChatToolJson)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, ChatClientJsonContext.Default.AzureAIChatToolJson)); + return new(new FunctionDefinition(aiFunction.Name) + { + Description = aiFunction.Description, + Parameters = functionParameters, + }); + } + + /// Converts an Extensions chat message enumerable to an AzureAI chat message enumerable. + 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. + + foreach (ChatMessage input in inputs) + { + if (input.Role == ChatRole.System) + { + yield return new ChatRequestSystemMessage(input.Text ?? string.Empty); + } + else if (input.Role == ChatRoleDeveloper) + { + yield return new ChatRequestDeveloperMessage(input.Text ?? string.Empty); + } + 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, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + } + catch (NotSupportedException) + { + // If the type can't be serialized, skip it. + } + } + + yield return new ChatRequestToolMessage(result ?? string.Empty, resultContent.CallId); + } + } + } + else if (input.Role == ChatRole.User) + { + if (input.Contents.Count > 0) + { + if (input.Contents.All(c => c is TextContent)) + { + if (string.Concat(input.Contents) is { Length: > 0 } text) + { + yield return new ChatRequestUserMessage(text); + } + } + else if (GetContentParts(input.Contents) is { Count: > 0 } parts) + { + yield return new ChatRequestUserMessage(parts); + } + } + } + else if (input.Role == ChatRole.Assistant) + { + // TODO: ChatRequestAssistantMessage only enables text content currently. + // Update it with other content types when it supports that. + ChatRequestAssistantMessage message = new(string.Concat(input.Contents.Where(c => c is TextContent))); + + foreach (var content in input.Contents) + { + if (content is FunctionCallContent { CallId: not null } callRequest) + { + message.ToolCalls.Add(new ChatCompletionsToolCall( + callRequest.CallId, + new FunctionCall( + callRequest.Name, + JsonSerializer.Serialize(callRequest.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))); + } + } + + yield return message; + } + } + } + + /// Converts a list of to a list of . + private static List GetContentParts(IList contents) + { + Debug.Assert(contents is { Count: > 0 }, "Expected non-empty contents"); + + List parts = []; + foreach (var content in contents) + { + switch (content) + { + case TextContent textContent: + parts.Add(new ChatMessageTextContentItem(textContent.Text)); + break; + + case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): + parts.Add(new ChatMessageImageContentItem(uriContent.Uri)); + break; + + case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): + parts.Add(new ChatMessageImageContentItem(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType)); + break; + + case UriContent uriContent when uriContent.HasTopLevelMediaType("audio"): + parts.Add(new ChatMessageAudioContentItem(uriContent.Uri)); + break; + + case DataContent dataContent when dataContent.HasTopLevelMediaType("audio"): + AudioContentFormat format; + if (dataContent.MediaType.Equals("audio/mpeg", StringComparison.OrdinalIgnoreCase)) + { + format = AudioContentFormat.Mp3; + } + else if (dataContent.MediaType.Equals("audio/wav", StringComparison.OrdinalIgnoreCase)) + { + format = AudioContentFormat.Wav; + } + else + { + break; + } + + parts.Add(new ChatMessageAudioContentItem(BinaryData.FromBytes(dataContent.Data), format)); + break; + } + } + + return parts; + } + + private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(json, callId, name, + argumentParser: static json => JsonSerializer.Deserialize(json, + (JsonTypeInfo>)AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))!); + + /// Source-generated JSON type information. + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] + [JsonSerializable(typeof(AzureAIChatToolJson))] + internal sealed partial class ChatClientJsonContext : JsonSerializerContext; + + /// Used to create the JSON payload for an AzureAI chat tool description. + internal sealed class AzureAIChatToolJson + { + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public List Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + } +} diff --git a/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceEmbeddingGenerator.cs b/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceEmbeddingGenerator.cs new file mode 100644 index 000000000000..c80d7637a64a --- /dev/null +++ b/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceEmbeddingGenerator.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Buffers.Text; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Inference; + +namespace Microsoft.Extensions.AI; + +/// Represents an for an Azure.AI.Inference . +internal sealed class AzureAIInferenceEmbeddingGenerator : + IEmbeddingGenerator> +{ + /// Metadata about the embedding generator. + private readonly EmbeddingGeneratorMetadata _metadata; + + /// The underlying . + private readonly EmbeddingsClient _embeddingsClient; + + /// The number of dimensions produced by the generator. + private readonly int? _dimensions; + + /// 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. + /// is . + /// is empty or composed entirely of whitespace. + /// is not positive. + public AzureAIInferenceEmbeddingGenerator( + EmbeddingsClient embeddingsClient, string? defaultModelId = null, int? defaultModelDimensions = null) + { + Argument.AssertNotNull(embeddingsClient, nameof(embeddingsClient)); + + if (defaultModelId is not null) + { + Argument.AssertNotNullOrWhiteSpace(defaultModelId, nameof(defaultModelId)); + } + + if (defaultModelDimensions is { } modelDimensions) + { + Argument.AssertInRange(modelDimensions, 1, int.MaxValue, nameof(defaultModelDimensions)); + } + + _embeddingsClient = embeddingsClient; + _dimensions = defaultModelDimensions; + _metadata = new EmbeddingGeneratorMetadata("az.ai.inference", embeddingsClient.Endpoint, defaultModelId, defaultModelDimensions); + } + + /// + object? IEmbeddingGenerator.GetService(Type serviceType, object? serviceKey) + { + Argument.AssertNotNull(serviceType, nameof(serviceType)); + + return + serviceKey is not null ? null : + serviceType == typeof(EmbeddingsClient) ? _embeddingsClient : + serviceType == typeof(EmbeddingGeneratorMetadata) ? _metadata : + serviceType.IsInstanceOfType(this) ? this : + null; + } + + /// + public async Task>> GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(values, nameof(values)); + + var azureAIOptions = ToAzureAIOptions(values, options); + + var embeddings = (await _embeddingsClient.EmbedAsync(azureAIOptions, cancellationToken).ConfigureAwait(false)).Value; + + GeneratedEmbeddings> result = new(embeddings.Data.Select(e => + new Embedding(ParseBase64Floats(e.Embedding)) + { + CreatedAt = DateTimeOffset.UtcNow, + ModelId = embeddings.Model ?? azureAIOptions.Model, + })); + + if (embeddings.Usage is not null) + { + result.Usage = new() + { + InputTokenCount = embeddings.Usage.PromptTokens, + TotalTokenCount = embeddings.Usage.TotalTokens + }; + } + + return result; + } + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IEmbeddingGenerator interface. + } + + internal static float[] ParseBase64Floats(BinaryData binaryData) + { + ReadOnlySpan base64 = binaryData.ToMemory().Span; + + // Remove quotes around base64 string. + if (base64.Length < 2 || base64[0] != (byte)'"' || base64[base64.Length - 1] != (byte)'"') + { + ThrowInvalidData(); + } + + base64 = base64.Slice(1, base64.Length - 2); + + // Decode base64 string to bytes. + byte[] bytes = ArrayPool.Shared.Rent(Base64.GetMaxDecodedFromUtf8Length(base64.Length)); + OperationStatus status = Base64.DecodeFromUtf8(base64, bytes.AsSpan(), out int bytesConsumed, out int bytesWritten); + if (status != OperationStatus.Done || bytesWritten % sizeof(float) != 0) + { + ThrowInvalidData(); + } + + // Interpret bytes as floats + float[] vector = new float[bytesWritten / sizeof(float)]; + bytes.AsSpan(0, bytesWritten).CopyTo(MemoryMarshal.AsBytes(vector.AsSpan())); + if (!BitConverter.IsLittleEndian) + { + Span ints = MemoryMarshal.Cast(vector.AsSpan()); +#if NET + BinaryPrimitives.ReverseEndianness(ints, ints); +#else + for (int i = 0; i < ints.Length; i++) + { + ints[i] = BinaryPrimitives.ReverseEndianness(ints[i]); + } +#endif + } + + ArrayPool.Shared.Return(bytes); + return vector; + + static void ThrowInvalidData() => + throw new FormatException("The input is not a valid Base64 string of encoded floats."); + } + + /// Converts an extensions options instance to an Azure.AI.Inference options instance. + private EmbeddingsOptions ToAzureAIOptions(IEnumerable inputs, EmbeddingGenerationOptions? options) + { + EmbeddingsOptions result = new(inputs) + { + Dimensions = options?.Dimensions ?? _dimensions, + Model = options?.ModelId ?? _metadata.DefaultModelId, + EncodingFormat = EmbeddingEncodingFormat.Base64, + }; + + if (options?.AdditionalProperties is { } props) + { + foreach (var prop in props) + { + if (prop.Value is not null) + { + byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + result.AdditionalProperties[prop.Key] = new BinaryData(data); + } + } + } + + return result; + } +} diff --git a/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceExtensions.cs b/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceExtensions.cs new file mode 100644 index 000000000000..fb099b2bd912 --- /dev/null +++ b/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using Azure.AI.Inference; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for working with Azure AI Inference. +public static class AzureAIInferenceExtensions +{ + /// Gets an for use with this . + /// The client. + /// 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 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 , 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> AsIEmbeddingGenerator( + this EmbeddingsClient embeddingsClient, string? defaultModelId = null, int? defaultModelDimensions = null) => + new AzureAIInferenceEmbeddingGenerator(embeddingsClient, defaultModelId, defaultModelDimensions); + + /// Gets an for use with this . + /// The client. + /// 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> AsIEmbeddingGenerator( + this ImageEmbeddingsClient imageEmbeddingsClient, string? defaultModelId = null, int? defaultModelDimensions = null) => + new AzureAIInferenceImageEmbeddingGenerator(imageEmbeddingsClient, defaultModelId, defaultModelDimensions); +} diff --git a/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceImageEmbeddingGenerator.cs b/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceImageEmbeddingGenerator.cs new file mode 100644 index 000000000000..ea7712f4349a --- /dev/null +++ b/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceImageEmbeddingGenerator.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Inference; + +namespace Microsoft.Extensions.AI; + +/// Represents an for an Azure.AI.Inference . +internal sealed class AzureAIInferenceImageEmbeddingGenerator : + IEmbeddingGenerator> +{ + /// Metadata about the embedding generator. + private readonly EmbeddingGeneratorMetadata _metadata; + + /// The underlying . + private readonly ImageEmbeddingsClient _imageEmbeddingsClient; + + /// The number of dimensions produced by the generator. + private readonly int? _dimensions; + + /// 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. + /// is . + /// is empty or composed entirely of whitespace. + /// is not positive. + public AzureAIInferenceImageEmbeddingGenerator( + ImageEmbeddingsClient imageEmbeddingsClient, string? defaultModelId = null, int? defaultModelDimensions = null) + { + Argument.AssertNotNull(imageEmbeddingsClient, nameof(imageEmbeddingsClient)); + + if (defaultModelId is not null) + { + Argument.AssertNotNullOrWhiteSpace(defaultModelId, nameof(defaultModelId)); + } + + if (defaultModelDimensions is { } modelDimensions) + { + Argument.AssertInRange(modelDimensions, 1, int.MaxValue, nameof(defaultModelDimensions)); + } + + _imageEmbeddingsClient = imageEmbeddingsClient; + _dimensions = defaultModelDimensions; + _metadata = new EmbeddingGeneratorMetadata("az.ai.inference", imageEmbeddingsClient.Endpoint, defaultModelId, defaultModelDimensions); + } + + /// + object? IEmbeddingGenerator.GetService(Type serviceType, object? serviceKey) + { + Argument.AssertNotNull(serviceType, nameof(serviceType)); + + return + serviceKey is not null ? null : + serviceType == typeof(ImageEmbeddingsClient) ? _imageEmbeddingsClient : + serviceType == typeof(EmbeddingGeneratorMetadata) ? _metadata : + serviceType.IsInstanceOfType(this) ? this : + null; + } + + /// + public async Task>> GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(values, nameof(values)); + + var azureAIOptions = ToAzureAIOptions(values, options); + + var embeddings = (await _imageEmbeddingsClient.EmbedAsync(azureAIOptions, cancellationToken).ConfigureAwait(false)).Value; + + GeneratedEmbeddings> result = new(embeddings.Data.Select(e => + new Embedding(AzureAIInferenceEmbeddingGenerator.ParseBase64Floats(e.Embedding)) + { + CreatedAt = DateTimeOffset.UtcNow, + ModelId = embeddings.Model ?? azureAIOptions.Model, + })); + + if (embeddings.Usage is not null) + { + result.Usage = new() + { + InputTokenCount = embeddings.Usage.PromptTokens, + TotalTokenCount = embeddings.Usage.TotalTokens + }; + } + + return result; + } + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IEmbeddingGenerator interface. + } + + /// Converts an extensions options instance to an Azure.AI.Inference options instance. + private ImageEmbeddingsOptions ToAzureAIOptions(IEnumerable inputs, EmbeddingGenerationOptions? options) + { + ImageEmbeddingsOptions result = new(inputs.Select(dc => new ImageEmbeddingInput(dc.Uri))) + { + Dimensions = options?.Dimensions ?? _dimensions, + Model = options?.ModelId ?? _metadata.DefaultModelId, + EncodingFormat = EmbeddingEncodingFormat.Base64, + }; + + if (options?.AdditionalProperties is { } props) + { + foreach (var prop in props) + { + if (prop.Value is not null) + { + byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + result.AdditionalProperties[prop.Key] = new BinaryData(data); + } + } + } + + return result; + } +} diff --git a/sdk/ai/Azure.AI.Inference/src/Customized/ChatCompletionsClient.cs b/sdk/ai/Azure.AI.Inference/src/Customized/ChatCompletionsClient.cs index f714560e3d65..8d311fe47c9c 100644 --- a/sdk/ai/Azure.AI.Inference/src/Customized/ChatCompletionsClient.cs +++ b/sdk/ai/Azure.AI.Inference/src/Customized/ChatCompletionsClient.cs @@ -23,6 +23,8 @@ namespace Azure.AI.Inference [CodeGenSuppress("CompleteAsync", typeof(ChatCompletionsOptions), typeof(ExtraParameters?), typeof(CancellationToken))] public partial class ChatCompletionsClient { + internal Uri Endpoint => _endpoint; + /// Initializes a new instance of ChatCompletionsClient. /// The to use. /// A credential used to authenticate to an Azure Service. diff --git a/sdk/ai/Azure.AI.Inference/src/Customized/EmbeddingsClient.cs b/sdk/ai/Azure.AI.Inference/src/Customized/EmbeddingsClient.cs index fb424512ff99..7ae5d6393bad 100644 --- a/sdk/ai/Azure.AI.Inference/src/Customized/EmbeddingsClient.cs +++ b/sdk/ai/Azure.AI.Inference/src/Customized/EmbeddingsClient.cs @@ -22,6 +22,8 @@ namespace Azure.AI.Inference [CodeGenSuppress("EmbedAsync", typeof(EmbeddingsOptions), typeof(ExtraParameters?), typeof(CancellationToken))] public partial class EmbeddingsClient { + internal Uri Endpoint => _endpoint; + /// Initializes a new instance of EmbeddingsClient. /// The to use. /// A credential used to authenticate to an Azure Service. diff --git a/sdk/ai/Azure.AI.Inference/src/Customized/ImageEmbeddingsClient.cs b/sdk/ai/Azure.AI.Inference/src/Customized/ImageEmbeddingsClient.cs index 088ec49f1918..e9406986560d 100644 --- a/sdk/ai/Azure.AI.Inference/src/Customized/ImageEmbeddingsClient.cs +++ b/sdk/ai/Azure.AI.Inference/src/Customized/ImageEmbeddingsClient.cs @@ -20,6 +20,8 @@ namespace Azure.AI.Inference [CodeGenSuppress("EmbedAsync", typeof(ImageEmbeddingsOptions), typeof(ExtraParameters?), typeof(CancellationToken))] public partial class ImageEmbeddingsClient { + internal Uri Endpoint => _endpoint; + /// Initializes a new instance of ImageEmbeddingsClient. /// Service host. /// A credential used to authenticate to an Azure Service. From 3e14a87fbb154b10b7c7c0ad1376e4da7a881a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Thu, 15 May 2025 11:23:47 -0500 Subject: [PATCH 2/3] Port unit tests --- eng/Packages.Data.props | 2 + .../Customized/AzureAIInferenceChatClient.cs | 2 +- .../tests/Azure.AI.Inference.Tests.csproj | 6 + .../tests/AzureAIInferenceChatClientTests.cs | 1433 +++++++++++++++++ ...AzureAIInferenceEmbeddingGeneratorTests.cs | 127 ++ ...AIInferenceImageEmbeddingGeneratorTests.cs | 140 ++ .../tests/Utilities/VerbatimHttpHandler.cs | 94 ++ 7 files changed, 1803 insertions(+), 1 deletion(-) create mode 100644 sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceChatClientTests.cs create mode 100644 sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceEmbeddingGeneratorTests.cs create mode 100644 sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs create mode 100644 sdk/ai/Azure.AI.Inference/tests/Utilities/VerbatimHttpHandler.cs diff --git a/eng/Packages.Data.props b/eng/Packages.Data.props index 801f30b967fd..03cf351ef1dd 100644 --- a/eng/Packages.Data.props +++ b/eng/Packages.Data.props @@ -375,7 +375,9 @@ + + diff --git a/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceChatClient.cs b/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceChatClient.cs index b85b00273d85..8796aa55d611 100644 --- a/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceChatClient.cs +++ b/sdk/ai/Azure.AI.Inference/src/Customized/AzureAIInferenceChatClient.cs @@ -60,7 +60,7 @@ public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, s /// object? IChatClient.GetService(Type serviceType, object? serviceKey) { - Argument.AssertNotNull(serviceKey, nameof(serviceKey)); + Argument.AssertNotNull(serviceType, nameof(serviceType)); return serviceKey is not null ? null : diff --git a/sdk/ai/Azure.AI.Inference/tests/Azure.AI.Inference.Tests.csproj b/sdk/ai/Azure.AI.Inference/tests/Azure.AI.Inference.Tests.csproj index 9f2a02e51766..4a9d992cd98c 100644 --- a/sdk/ai/Azure.AI.Inference/tests/Azure.AI.Inference.Tests.csproj +++ b/sdk/ai/Azure.AI.Inference/tests/Azure.AI.Inference.Tests.csproj @@ -14,6 +14,8 @@ + + @@ -22,4 +24,8 @@ + + + + diff --git a/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceChatClientTests.cs b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceChatClientTests.cs new file mode 100644 index 000000000000..cbe9524e8f13 --- /dev/null +++ b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceChatClientTests.cs @@ -0,0 +1,1433 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Azure; +using Azure.AI.Inference; +using Azure.Core.Pipeline; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using NUnit.Framework; +using NUnit.Framework.Internal; + +namespace Microsoft.Extensions.AI; + +public class AzureAIInferenceChatClientTests +{ + [Test] + public void AsIChatClient_InvalidArgs_Throws() + { + var ex = Assert.Throws(() => ((ChatCompletionsClient)null!).AsIChatClient("model")); + Assert.That(ex!.ParamName, Is.EqualTo("chatCompletionsClient")); + + ChatCompletionsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); + var ex2 = Assert.Throws(() => client.AsIChatClient(" ")); + Assert.That(ex2!.ParamName, Is.EqualTo("defaultModelId")); + } + + [Test] + public void NullModel_Throws() + { + ChatCompletionsClient client = new(new("http://localhost/some/endpoint"), new AzureKeyCredential("key")); + IChatClient chatClient = client.AsIChatClient(modelId: null); + + Assert.ThrowsAsync(() => chatClient.GetResponseAsync("hello")); + Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("hello").GetAsyncEnumerator().MoveNextAsync().AsTask()); + + Assert.ThrowsAsync(() => chatClient.GetResponseAsync("hello", new ChatOptions { ModelId = null })); + Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("hello", new ChatOptions { ModelId = null }).GetAsyncEnumerator().MoveNextAsync().AsTask()); + } + + [Test] + public void AsIChatClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + ChatCompletionsClient client = new(endpoint, new AzureKeyCredential("key")); + + IChatClient chatClient = client.AsIChatClient(model); + var metadata = chatClient.GetService(); + Assert.That(metadata?.ProviderName, Is.EqualTo("az.ai.inference")); + Assert.That(metadata?.ProviderUri, Is.EqualTo(endpoint)); + Assert.That(metadata?.DefaultModelId, Is.EqualTo(model)); + } + + [Test] + public void GetService_SuccessfullyReturnsUnderlyingClient() + { + ChatCompletionsClient client = new(new("http://localhost"), new AzureKeyCredential("key")); + IChatClient chatClient = client.AsIChatClient("model"); + + Assert.That(chatClient.GetService(), Is.SameAs(chatClient)); + Assert.That(chatClient.GetService(), Is.SameAs(client)); + + using IChatClient pipeline = chatClient + .AsBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Build(); + + Assert.That(pipeline.GetService(), Is.Not.Null); + Assert.That(pipeline.GetService(), Is.Not.Null); + Assert.That(pipeline.GetService(), Is.Not.Null); + Assert.That(pipeline.GetService(), Is.Not.Null); + Assert.That(pipeline.GetService(), Is.Not.Null); + + Assert.That(pipeline.GetService(), Is.SameAs(client)); + Assert.That(pipeline.GetService(), Is.InstanceOf()); + + Assert.That(pipeline.GetService("key"), Is.Null); + Assert.That(pipeline.GetService("key"), Is.Null); + } + + private const string BasicInputNonStreaming = """ + { + "messages": [{"role":"user", "content":"hello"}], + "max_tokens":10, + "temperature":0.5, + "model":"gpt-4o-mini" + } + """; + + private const string BasicOutputNonStreaming = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "created": 1727888631, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 8, + "completion_tokens": 9, + "total_tokens": 17, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + [TestCase(false)] + [TestCase(true)] + public async Task BasicRequestResponse_NonStreaming(bool multiContent) + { + using VerbatimHttpHandler handler = new(BasicInputNonStreaming, BasicOutputNonStreaming); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List messages = multiContent ? + [new ChatMessage(ChatRole.User, "hello".Select(c => (AIContent)new TextContent(c.ToString())).ToList())] : + [new ChatMessage(ChatRole.User, "hello")]; + + var response = await client.GetResponseAsync(messages, new() + { + MaxOutputTokens = 10, + Temperature = 0.5f, + }); + Assert.That(response, Is.Not.Null); + + Assert.That(response.ResponseId, Is.EqualTo("chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI")); + Assert.That(response.Text, Is.EqualTo("Hello! How can I assist you today?")); + Assert.That(response.Messages.Single().Contents, Has.Count.EqualTo(1)); + Assert.That(response.Messages.Single().Role, Is.EqualTo(ChatRole.Assistant)); + Assert.That(response.Messages.Single().MessageId, Is.EqualTo("chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI")); + Assert.That(response.ModelId, Is.EqualTo("gpt-4o-mini-2024-07-18")); + Assert.That(response.CreatedAt, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(1_727_888_631))); + Assert.That(response.FinishReason, Is.EqualTo(ChatFinishReason.Stop)); + + Assert.That(response.Usage, Is.Not.Null); + Assert.That(response.Usage!.InputTokenCount, Is.EqualTo(8)); + Assert.That(response.Usage.OutputTokenCount, Is.EqualTo(9)); + Assert.That(response.Usage.TotalTokenCount, Is.EqualTo(17)); + } + + private const string BasicInputStreaming = """ + { + "messages": [{"role":"user", "content":"hello"}], + "max_tokens":20, + "temperature":0.5, + "stream":true, + "model":"gpt-4o-mini"} + """; + + private const string BasicOutputStreaming = """ + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" How"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" can"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" assist"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" today"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":8,"completion_tokens":9,"total_tokens":17,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}} + + data: [DONE] + + """; + + [TestCase(false)] + [TestCase(true)] + public async Task BasicRequestResponse_Streaming(bool multiContent) + { + using VerbatimHttpHandler handler = new(BasicInputStreaming, BasicOutputStreaming); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List messages = multiContent ? + [new ChatMessage(ChatRole.User, "hello".Select(c => (AIContent)new TextContent(c.ToString())).ToList())] : + [new ChatMessage(ChatRole.User, "hello")]; + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync(messages, new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + })) + { + updates.Add(update); + } + + Assert.That(string.Concat(updates.Select(u => u.Text)), Is.EqualTo("Hello! How can I assist you today?")); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_727_889_370); + Assert.That(updates.Count, Is.EqualTo(12)); + for (int i = 0; i < updates.Count; i++) + { + Assert.That(updates[i].ResponseId, Is.EqualTo("chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK")); + Assert.That(updates[i].MessageId, Is.EqualTo("chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK")); + Assert.That(updates[i].CreatedAt, Is.EqualTo(createdAt)); + Assert.That(updates[i].ModelId, Is.EqualTo("gpt-4o-mini-2024-07-18")); + Assert.That(updates[i].Role, Is.EqualTo(ChatRole.Assistant)); + Assert.That(updates[i].Contents.Count, Is.EqualTo(i is < 10 or 11 ? 1 : 0)); + Assert.That(updates[i].FinishReason, Is.EqualTo(i < 10 ? null : ChatFinishReason.Stop)); + } + } + + [Test] + public async Task IChatClient_WithNullModel_ChatOptions_WithNotNullModel_NonStreaming() + { + using VerbatimHttpHandler handler = new(BasicInputNonStreaming, BasicOutputNonStreaming); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + + var response = await client.GetResponseAsync("hello", new ChatOptions + { + ModelId = "gpt-4o-mini", + MaxOutputTokens = 10, + Temperature = 0.5f, + }); + Assert.That(response, Is.Not.Null); + Assert.That(response.Text, Is.EqualTo("Hello! How can I assist you today?")); + } + + [Test] + public async Task IChatClient_WithNullModel_ChatOptions_WithNotNullModel_Streaming() + { + using VerbatimHttpHandler handler = new(BasicInputStreaming, BasicOutputStreaming); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + + string responseText = string.Empty; + await foreach (var update in client.GetStreamingResponseAsync("hello", new ChatOptions + { + ModelId = "gpt-4o-mini", + MaxOutputTokens = 20, + Temperature = 0.5f, + })) + { + responseText += update.Text; + } + + Assert.That(responseText, Is.EqualTo("Hello! How can I assist you today?")); + } + + [Test] + public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.75, + "max_tokens":10, + "top_p":0.5, + "presence_penalty":0.5, + "temperature":0.5, + "seed":42, + "stop":["hello","world"], + "response_format":{"type":"text"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}}, + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type": "object","required": ["personName"],"properties": {"personName": {"description": "The person whose age is being requested","type": "string"}}}}} + ], + "tool_choice":"auto", + "additional_property_from_raw_representation":42, + "additional_property_from_MEAI_options":42 + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + AIFunction tool = AIFunctionFactory.Create(([System.ComponentModel.Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatOptions chatOptions = new ChatOptions + { + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new() + { + Model = "gpt-4o-mini", + FrequencyPenalty = 0.75f, + MaxTokens = 10, + NucleusSamplingFactor = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, + Seed = 42, + ToolChoice = ChatCompletionsToolChoice.Auto, + ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat() + }; + azureAIOptions.StopSequences.Add("hello"); + azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); + azureAIOptions.AdditionalProperties["additional_property_from_raw_representation"] = new BinaryData("42"); + return azureAIOptions; + }, + ModelId = null, + FrequencyPenalty = 0.125f, + MaxOutputTokens = 1, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["additional_property_from_MEAI_options"] = 42 + } + }; + + var response = await client.GetResponseAsync("hello", chatOptions); + Assert.That(response, Is.Not.Null); + Assert.That(response.Text, Is.EqualTo("Hello! How can I assist you today?")); + } + + [Test] + public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_Streaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.75, + "max_tokens":10, + "top_p":0.5, + "presence_penalty":0.5, + "temperature":0.5, + "seed":42, + "stop":["hello","world"], + "response_format":{"type":"text"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}}, + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type": "object","required": ["personName"],"properties": {"personName": {"description": "The person whose age is being requested","type": "string"}}}}} + ], + "tool_choice":"auto", + "additional_property_from_raw_representation":42, + "additional_property_from_MEAI_options":42, + "stream":true + } + """; + + const string Output = """ + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Hello! "}}]} + + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"content":"How can I assist you today?"}}]} + + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]} + + data: [DONE] + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + AIFunction tool = AIFunctionFactory.Create(([System.ComponentModel.Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatOptions chatOptions = new ChatOptions + { + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new() + { + Model = "gpt-4o-mini", + FrequencyPenalty = 0.75f, + MaxTokens = 10, + NucleusSamplingFactor = 0.5f, + PresencePenalty = 0.5f, + Temperature = 0.5f, + Seed = 42, + ToolChoice = ChatCompletionsToolChoice.Auto, + ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat() + }; + azureAIOptions.StopSequences.Add("hello"); + azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); + azureAIOptions.AdditionalProperties["additional_property_from_raw_representation"] = new BinaryData("42"); + return azureAIOptions; + }, + ModelId = null, + FrequencyPenalty = 0.125f, + MaxOutputTokens = 1, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["additional_property_from_MEAI_options"] = 42 + } + }; + + string responseText = string.Empty; + await foreach (var update in client.GetStreamingResponseAsync("hello", chatOptions)) + { + responseText += update.Text; + } + + Assert.That(responseText, Is.EqualTo("Hello! How can I assist you today?")); + } + + [Test] + public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.125, + "max_tokens":1, + "top_p":0.125, + "presence_penalty":0.125, + "temperature":0.125, + "seed":1, + "stop":["world"], + "response_format":{"type":"json_object"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} + ], + "tool_choice":"none" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + AIFunction tool = AIFunctionFactory.Create(([System.ComponentModel.Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatOptions chatOptions = new ChatOptions + { + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new(); + Assert.That(azureAIOptions.Messages, Is.Empty); + Assert.That(azureAIOptions.Model, Is.Null); + Assert.That(azureAIOptions.FrequencyPenalty, Is.Null); + Assert.That(azureAIOptions.MaxTokens, Is.Null); + Assert.That(azureAIOptions.NucleusSamplingFactor, Is.Null); + Assert.That(azureAIOptions.PresencePenalty, Is.Null); + Assert.That(azureAIOptions.Temperature, Is.Null); + Assert.That(azureAIOptions.Seed, Is.Null); + Assert.That(azureAIOptions.StopSequences, Is.Empty); + Assert.That(azureAIOptions.Tools, Is.Empty); + Assert.That(azureAIOptions.ToolChoice, Is.Null); + Assert.That(azureAIOptions.ResponseFormat, Is.Null); + return azureAIOptions; + }, + ModelId = "gpt-4o-mini", + FrequencyPenalty = 0.125f, + MaxOutputTokens = 1, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + var response = await client.GetResponseAsync("hello", chatOptions); + Assert.That(response, Is.Not.Null); + Assert.That(response.Text, Is.EqualTo("Hello! How can I assist you today?")); + } + + [Test] + public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Streaming() + { + const string Input = """ + { + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "frequency_penalty":0.125, + "max_tokens":1, + "top_p":0.125, + "presence_penalty":0.125, + "temperature":0.125, + "seed":1, + "stop":["world"], + "response_format":{"type":"json_object"}, + "tools":[ + {"type":"function","function":{"name":"GetPersonAge","description":"Gets the age of the specified person.","parameters":{"type":"object","required":["personName"],"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}}}}} + ], + "tool_choice":"none", + "stream":true + } + """; + + const string Output = """ + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Hello! "}}]} + + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{"content":"How can I assist you today?"}}]} + + data: {"id":"chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]} + + data: [DONE] + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, modelId: null!); + AIFunction tool = AIFunctionFactory.Create(([System.ComponentModel.Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatOptions chatOptions = new ChatOptions + { + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new(); + Assert.That(azureAIOptions.Messages, Is.Empty); + Assert.That(azureAIOptions.Model, Is.Null); + Assert.That(azureAIOptions.FrequencyPenalty, Is.Null); + Assert.That(azureAIOptions.MaxTokens, Is.Null); + Assert.That(azureAIOptions.NucleusSamplingFactor, Is.Null); + Assert.That(azureAIOptions.PresencePenalty, Is.Null); + Assert.That(azureAIOptions.Temperature, Is.Null); + Assert.That(azureAIOptions.Seed, Is.Null); + Assert.That(azureAIOptions.StopSequences, Is.Empty); + Assert.That(azureAIOptions.Tools, Is.Empty); + Assert.That(azureAIOptions.ToolChoice, Is.Null); + Assert.That(azureAIOptions.ResponseFormat, Is.Null); + return azureAIOptions; + }, + ModelId = "gpt-4o-mini", + FrequencyPenalty = 0.125f, + MaxOutputTokens = 1, + TopP = 0.125f, + PresencePenalty = 0.125f, + Temperature = 0.125f, + Seed = 1, + StopSequences = ["world"], + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + string responseText = string.Empty; + await foreach (var update in client.GetStreamingResponseAsync("hello", chatOptions)) + { + responseText += update.Text; + } + + Assert.That(responseText, Is.EqualTo("Hello! How can I assist you today?")); + } + + /// Converts an Extensions function to an AzureAI chat tool. + private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunction) + { + // Map to an intermediate model so that redundant properties are skipped. + var tool = JsonSerializer.Deserialize(aiFunction.JsonSchema)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool)); + return new(new FunctionDefinition(aiFunction.Name) + { + Description = aiFunction.Description, + Parameters = functionParameters, + }); + } + + /// Used to create the JSON payload for an AzureAI chat tool description. + private sealed class AzureAIChatToolJson + { + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public List Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + } + + [Test] + public async Task AdditionalOptions_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user", "content":"hello"}], + "max_tokens":10, + "temperature":0.5, + "top_p":0.5, + "stop":["yes","no"], + "presence_penalty":0.5, + "frequency_penalty":0.75, + "seed":42, + "model":"gpt-4o-mini", + "top_k":40, + "something_else":"value1", + "and_something_further":123 + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.That(await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 10, + Temperature = 0.5f, + TopP = 0.5f, + TopK = 40, + FrequencyPenalty = 0.75f, + PresencePenalty = 0.5f, + Seed = 42, + StopSequences = ["yes", "no"], + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new(); + azureAIOptions.AdditionalProperties.Add("something_else", new BinaryData(JsonSerializer.SerializeToUtf8Bytes("value1", typeof(object)))); + azureAIOptions.AdditionalProperties.Add("and_something_further", new BinaryData(JsonSerializer.SerializeToUtf8Bytes(123, typeof(object)))); + return azureAIOptions; + }, + }), Is.Not.Null); + } + + [Test] + public async Task TopK_DoNotOverwrite_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user", "content":"hello"}], + "max_tokens":10, + "temperature":0.5, + "top_p":0.5, + "stop":["yes","no"], + "presence_penalty":0.5, + "frequency_penalty":0.75, + "seed":42, + "model":"gpt-4o-mini", + "top_k":40, + "something_else":"value1", + "and_something_further":123 + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.That(await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 10, + Temperature = 0.5f, + TopP = 0.5f, + TopK = 20, // will be ignored because the raw representation already specifies it. + FrequencyPenalty = 0.75f, + PresencePenalty = 0.5f, + Seed = 42, + StopSequences = ["yes", "no"], + RawRepresentationFactory = (c) => + { + ChatCompletionsOptions azureAIOptions = new(); + azureAIOptions.AdditionalProperties.Add("top_k", new BinaryData(JsonSerializer.SerializeToUtf8Bytes(40, typeof(object)))); + azureAIOptions.AdditionalProperties.Add("something_else", new BinaryData(JsonSerializer.SerializeToUtf8Bytes("value1", typeof(object)))); + azureAIOptions.AdditionalProperties.Add("and_something_further", new BinaryData(JsonSerializer.SerializeToUtf8Bytes(123, typeof(object)))); + return azureAIOptions; + }, + }), Is.Not.Null); + } + + [Test] + public async Task ResponseFormat_Text_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user", "content":"hello"}], + "model":"gpt-4o-mini", + "response_format":{"type":"text"} + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.That(await client.GetResponseAsync("hello", new() + { + ResponseFormat = ChatResponseFormat.Text, + }), Is.Not.Null); + } + + [Test] + public async Task ResponseFormat_Json_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user", "content":"hello"}], + "model":"gpt-4o-mini", + "response_format":{"type":"json_object"} + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.That(await client.GetResponseAsync("hello", new() + { + ResponseFormat = ChatResponseFormat.Json, + }), Is.Not.Null); + } + + [Test] + public async Task ResponseFormat_JsonSchema_NonStreaming() + { + const string Input = """ + { + "messages":[{"role":"user", "content":"hello"}], + "model":"gpt-4o-mini", + "response_format": + { + "type":"json_schema", + "json_schema": + { + "name": "DescribedObject", + "schema": + { + "type":"object", + "properties": + { + "description": + { + "type":"string" + } + }, + "required":["description"], + "additionalProperties":false + }, + "description":"An object with a description" + } + } + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.That(await client.GetResponseAsync("hello", new() + { + ResponseFormat = ChatResponseFormat.ForJsonSchema(JsonSerializer.Deserialize(""" + { + "type": "object", + "properties": { + "description": { + "type": "string" + } + }, + "required": ["description"] + } + """), "DescribedObject", "An object with a description"), + }), Is.Not.Null); + } + + [Test] + public async Task MultipleMessages_NonStreaming() + { + const string Input = """ + { + "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?" + }, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id":"abcd123","type":"function","function":{"name":"GetMood","arguments":"null"}}] + }, + { + "role": "tool", + "content": "happy", + "tool_call_id": "abcd123" + } + ], + "temperature": 0.25, + "stop": [ + "great" + ], + "presence_penalty": 0.5, + "frequency_penalty": 0.75, + "seed": 42, + "model": "gpt-4o-mini" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I'm doing well, thank you! What's on your mind today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 42, + "completion_tokens": 15, + "total_tokens": 57, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List messages = + [ + new(ChatRole.System, "You are a really nice friend."), + new(ChatRole.User, "hello!"), + new(ChatRole.Assistant, "hi, how are you?"), + new(ChatRole.User, "i'm good. how are you?"), + new(ChatRole.Assistant, [new FunctionCallContent("abcd123", "GetMood")]), + new(ChatRole.Tool, [new FunctionResultContent("abcd123", "happy")]), + ]; + + var response = await client.GetResponseAsync(messages, new() + { + Temperature = 0.25f, + FrequencyPenalty = 0.75f, + PresencePenalty = 0.5f, + StopSequences = ["great"], + Seed = 42, + }); + Assert.That(response, Is.Not.Null); + + Assert.That(response.ResponseId, Is.EqualTo("chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P")); + Assert.That(response.Text, Is.EqualTo("I'm doing well, thank you! What's on your mind today?")); + Assert.That(response.Messages.Single().Contents, Has.Count.EqualTo(1)); + Assert.That(response.Messages.Single().Role, Is.EqualTo(ChatRole.Assistant)); + Assert.That(response.Messages.Single().MessageId, Is.EqualTo("chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P")); + Assert.That(response.ModelId, Is.EqualTo("gpt-4o-mini-2024-07-18")); + Assert.That(response.CreatedAt, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(1_727_894_187))); + Assert.That(response.FinishReason, Is.EqualTo(ChatFinishReason.Stop)); + + Assert.That(response.Usage, Is.Not.Null); + Assert.That(response.Usage!.InputTokenCount, Is.EqualTo(42)); + Assert.That(response.Usage.OutputTokenCount, Is.EqualTo(15)); + Assert.That(response.Usage.TotalTokenCount, Is.EqualTo(57)); + } + + [Test] + public async Task MultipleContent_NonStreaming() + { + const string Input = """ + { + "messages": + [ + { + "role": "user", + "content": + [ + { + "type": "text", + "text": "Describe this picture." + }, + { + "type": "image_url", + "image_url": + { + "url": "http://dot.net/someimage.png" + } + } + ] + } + ], + "model": "gpt-4o-mini" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "A picture of a dog." + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.That(await client.GetResponseAsync([new(ChatRole.User, + [ + new TextContent("Describe this picture."), + new UriContent("http://dot.net/someimage.png", mediaType: "image/*"), + ])]), Is.Not.Null); + } + + [Test] + public async Task NullAssistantText_ContentEmpty_NonStreaming() + { + const string Input = """ + { + "messages": [ + { + "role": "assistant", + "content": "" + }, + { + "role": "user", + "content": "hello!" + } + ], + "model": "gpt-4o-mini" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello.", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 42, + "completion_tokens": 15, + "total_tokens": 57, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List messages = + [ + new(ChatRole.Assistant, (string?)null), + new(ChatRole.User, "hello!"), + ]; + + var response = await client.GetResponseAsync(messages); + Assert.That(response, Is.Not.Null); + + Assert.That(response.ResponseId, Is.EqualTo("chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P")); + Assert.That(response.Text, Is.EqualTo("Hello.")); + Assert.That(response.Messages.Single().Contents, Has.Count.EqualTo(1)); + Assert.That(response.Messages.Single().Role, Is.EqualTo(ChatRole.Assistant)); + Assert.That(response.ModelId, Is.EqualTo("gpt-4o-mini-2024-07-18")); + Assert.That(response.CreatedAt, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(1_727_894_187))); + Assert.That(response.FinishReason, Is.EqualTo(ChatFinishReason.Stop)); + + Assert.That(response.Usage, Is.Not.Null); + Assert.That(response.Usage!.InputTokenCount, Is.EqualTo(42)); + Assert.That(response.Usage.OutputTokenCount, Is.EqualTo(15)); + Assert.That(response.Usage.TotalTokenCount, Is.EqualTo(57)); + } + + public static IEnumerable FunctionCallContent_NonStreaming_MemberData() + { + yield return new object[] { ChatToolMode.Auto }; + yield return new object[] { ChatToolMode.None }; + yield return new object[] { ChatToolMode.RequireAny }; + yield return new object[] { ChatToolMode.RequireSpecific("GetPersonAge") }; + } + + [TestCaseSource(nameof(FunctionCallContent_NonStreaming_MemberData))] + public async Task FunctionCallContent_NonStreaming(ChatToolMode mode) + { + string input = $$""" + { + "messages": [ + { + "role": "user", + "content": "How old is Alice?" + } + ], + "model": "gpt-4o-mini", + "tools": [ + { + "type": "function", + "function": { + "name": "GetPersonAge", + "description": "Gets the age of the specified person.", + "parameters": { + "type": "object", + "required": ["personName"], + "properties": { + "personName": { + "description": "The person whose age is being requested", + "type": "string" + } + } + } + } + } + ], + "tool_choice": {{( + mode is NoneChatToolMode ? "\"none\"" : + mode is AutoChatToolMode ? "\"auto\"" : + mode is RequiredChatToolMode { RequiredFunctionName: not null } f ? "{\"type\":\"function\",\"function\":{\"name\":\"GetPersonAge\"}}" : + "\"required\"" + )}} + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADydKhrSKEBWJ8gy0KCIU74rN3Hmk", + "object": "chat.completion", + "created": 1727894702, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_8qbINM045wlmKZt9bVJgwAym", + "type": "function", + "function": { + "name": "GetPersonAge", + "arguments": "{\"personName\":\"Alice\"}" + } + } + ], + "refusal": null + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 61, + "completion_tokens": 16, + "total_tokens": 77, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("How old is Alice?", new() + { + Tools = [AIFunctionFactory.Create(([System.ComponentModel.Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], + ToolMode = mode, + }); + Assert.That(response, Is.Not.Null); + + Assert.That(response.Text, Is.Empty); + Assert.That(response.ModelId, Is.EqualTo("gpt-4o-mini-2024-07-18")); + Assert.That(response.Messages.Single().Role, Is.EqualTo(ChatRole.Assistant)); + Assert.That(response.CreatedAt, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(1_727_894_702))); + Assert.That(response.FinishReason, Is.EqualTo(ChatFinishReason.ToolCalls)); + Assert.That(response.Usage, Is.Not.Null); + Assert.That(response.Usage!.InputTokenCount, Is.EqualTo(61)); + Assert.That(response.Usage.OutputTokenCount, Is.EqualTo(16)); + Assert.That(response.Usage.TotalTokenCount, Is.EqualTo(77)); + + Assert.That(response.Messages.Single().Contents, Has.Count.EqualTo(1)); + + var aiContent = response.Messages.Single().Contents[0]; + Assert.That(aiContent, Is.InstanceOf()); + var fcc = (FunctionCallContent)aiContent; + Assert.That(fcc.Name, Is.EqualTo("GetPersonAge")); + AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); + } + + [Test] + public async Task FunctionCallContent_Streaming() + { + const string Input = """ + { + "messages": [ + { + "role": "user", + "content": "How old is Alice?" + } + ], + "stream": true, + "model": "gpt-4o-mini", + "tools": [ + { + "type": "function", + "function": { + "name": "GetPersonAge", + "description": "Gets the age of the specified person.", + "parameters": { + "type": "object", + "required": ["personName"], + "properties": { + "personName": { + "description": "The person whose age is being requested", + "type": "string" + } + } + } + } + } + ], + "tool_choice": "auto" + } + """; + + const string Output = """ + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_F9ZaqPWo69u0urxAhVt8meDW","type":"function","function":{"name":"GetPersonAge","arguments":""}}],"refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"person"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Name"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Alice"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":61,"completion_tokens":16,"total_tokens":77,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}} + + data: [DONE] + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("How old is Alice?", new() + { + Tools = [AIFunctionFactory.Create(([System.ComponentModel.Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], + })) + { + updates.Add(update); + } + + Assert.That(string.Concat(updates.Select(u => u.Text)), Is.EqualTo("")); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_727_895_263); + Assert.That(updates.Count, Is.EqualTo(10)); + for (int i = 0; i < updates.Count; i++) + { + Assert.That(updates[i].ResponseId, Is.EqualTo("chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl")); + Assert.That(updates[i].MessageId, Is.EqualTo("chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl")); + Assert.That(updates[i].CreatedAt, Is.EqualTo(createdAt)); + Assert.That(updates[i].ModelId, Is.EqualTo("gpt-4o-mini-2024-07-18")); + Assert.That(updates[i].Role, Is.EqualTo(ChatRole.Assistant)); + Assert.That(updates[i].FinishReason, Is.EqualTo(i < 7 ? null : ChatFinishReason.ToolCalls)); + } + + Assert.That(updates[updates.Count - 1].Contents.Count, Is.EqualTo(1)); + var aiContent = updates[updates.Count - 1].Contents[0]; + Assert.That(aiContent, Is.InstanceOf()); + var fcc = (FunctionCallContent)aiContent; + + Assert.That(fcc.CallId, Is.EqualTo("call_F9ZaqPWo69u0urxAhVt8meDW")); + Assert.That(fcc.Name, Is.EqualTo("GetPersonAge")); + AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); + } + + private static IChatClient CreateChatClient(HttpClient httpClient, string modelId) => + new ChatCompletionsClient( + new("http://somewhere"), + new AzureKeyCredential("key"), + new AzureAIInferenceClientOptions { Transport = new HttpClientTransport(httpClient) }) + .AsIChatClient(modelId); + + private static class AssertExtensions + { + /// + /// Asserts that the two function call parameters are equal, up to JSON equivalence. + /// + public static void EqualFunctionCallParameters( + IDictionary? expected, + IDictionary? actual, + JsonSerializerOptions? options = null) + { + if (expected is null || actual is null) + { + Assert.That(actual, Is.EqualTo(expected)); + return; + } + + foreach (var expectedEntry in expected) + { + if (!actual.TryGetValue(expectedEntry.Key, out object? actualValue)) + { + throw new AssertionException($"Expected parameter '{expectedEntry.Key}' not found in actual value."); + } + + AreJsonEquivalentValues(expectedEntry.Value, actualValue, options, propertyName: expectedEntry.Key); + } + + if (expected.Count != actual.Count) + { + var extraParameters = actual + .Where(e => !expected.ContainsKey(e.Key)) + .Select(e => $"'{e.Key}'") + .First(); + + throw new AssertionException($"Actual value contains additional parameters {string.Join(", ", extraParameters)} not found in expected value."); + } + } + + private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) + { + options ??= AIJsonUtilities.DefaultOptions; + JsonElement expectedElement = NormalizeToElement(expected, options); + JsonElement actualElement = NormalizeToElement(actual, options); + if (!JsonNode.DeepEquals( + JsonSerializer.SerializeToNode(expectedElement, AIJsonUtilities.DefaultOptions), + JsonSerializer.SerializeToNode(actualElement, AIJsonUtilities.DefaultOptions))) + { + string message = propertyName is null + ? $"Function result does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}" + : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}"; + + throw new AssertionException(message); + } + + static JsonElement NormalizeToElement(object? value, JsonSerializerOptions options) + => value is JsonElement e ? e : JsonSerializer.SerializeToElement(value, options); + } + } +} diff --git a/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceEmbeddingGeneratorTests.cs b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceEmbeddingGeneratorTests.cs new file mode 100644 index 000000000000..302267170e75 --- /dev/null +++ b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceEmbeddingGeneratorTests.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Azure; +using Azure.AI.Inference; +using Azure.Core.Pipeline; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using NUnit.Framework; + +namespace Microsoft.Extensions.AI; + +public class AzureAIInferenceEmbeddingGeneratorTests +{ + [Test] + public void AsIEmbeddingGenerator_InvalidArgs_Throws() + { + var ex = Assert.Throws(() => ((EmbeddingsClient)null!).AsIEmbeddingGenerator()); + Assert.That(ex!.ParamName, Is.EqualTo("embeddingsClient")); + + EmbeddingsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); + var ex2 = Assert.Throws(() => client.AsIEmbeddingGenerator(" ")); + Assert.That(ex2!.ParamName, Is.EqualTo("defaultModelId")); + + client.AsIEmbeddingGenerator(null); + } + + [Test] + public void AsIEmbeddingGenerator_AzureAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + EmbeddingsClient client = new(endpoint, new AzureKeyCredential("key")); + + IEmbeddingGenerator> embeddingGenerator = client.AsIEmbeddingGenerator(model); + var metadata = embeddingGenerator.GetService(); + Assert.That(metadata?.ProviderName, Is.EqualTo("az.ai.inference")); + Assert.That(metadata?.ProviderUri, Is.EqualTo(endpoint)); + Assert.That(metadata?.DefaultModelId, Is.EqualTo(model)); + } + + [Test] + public void GetService_SuccessfullyReturnsUnderlyingClient() + { + var client = new EmbeddingsClient(new("http://somewhere"), new AzureKeyCredential("key")); + var embeddingGenerator = client.AsIEmbeddingGenerator("model"); + + Assert.That(embeddingGenerator, Is.SameAs(embeddingGenerator.GetService>>())); + Assert.That(embeddingGenerator.GetService(), Is.SameAs(client)); + + using IEmbeddingGenerator> pipeline = embeddingGenerator + .AsBuilder() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Build(); + + Assert.That(pipeline.GetService>>(), Is.Not.Null); + Assert.That(pipeline.GetService>>(), Is.Not.Null); + Assert.That(pipeline.GetService>>(), Is.Not.Null); + + Assert.That(pipeline.GetService(), Is.SameAs(client)); + Assert.That(pipeline.GetService>>(), Is.TypeOf>>()); + } + + [Test] + public async Task GenerateAsync_ExpectedRequestResponse() + { + const string Input = """ + {"input":["hello, world!","red, white, blue"],"encoding_format":"base64","model":"text-embedding-3-small"} + """; + + const string Output = """ + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": "qjH+vMcj07wP1+U7kbwjOv4cwLyL3iy9DkgpvCkBQD0bthW98o6SvMMwmTrQRQa9r7b1uy4tuLzssJs7jZspPe0JG70KJy89ae4fPNLUwjytoHk9BX/1OlXCfTzc07M8JAMIPU7cibsUJiC8pTNGPWUbJztfwW69oNwOPQIQ+rwm60M7oAfOvDMAsTxb+fM77WIaPIverDqcu5S84f+rvFyr8rxqoB686/4cPVnj9ztLHw29mJqaPAhH8Lz/db86qga/PGhnYD1WST28YgWru1AdRTz/db899PIPPBzBE720ie47ujymPbh/Kb0scLs8V1Q7PGIFqzwVMR48xp+UOhNGYTxfwW67CaDvvOeEI7tgc228uQNoPXrLBztd2TI9HRqTvLuVJbytoPm8YVMsOvi6irzweJY7/WpBvI5NKL040ym95ccmPAfj8rxJCZG9bsGYvJkpVzszp7G8wOxcu6/ZN7xXrTo7Q90YvGTtZjz/SgA8RWxVPL/hXjynl8O8ZzGjvHK0Uj0dRVI954QjvaqKfTxmUeS8Abf6O0RhV7tr+R098rnRPAju8DtoiiK95SCmvGV0pjwQMOW9wJPdPPutxDxYivi8NLKvPI3pKj3UDYE9Fg5cvQsyrTz+HEC9uuMmPMEaHbzJ4E8778YXvVDERb2cFBS9tsIsPLU7bT3+R/+8b55WPLhRaTzsgls9Nb2tuhNG4btlzSW9Y7cpvO1iGr0lh0a8u8BkvadJQj24f6k9J51CvbAPdbwCEHq8CicvvIKROr0ESbg7GMvYPE6OCLxS2sG7/WrBPOzbWj3uP1i9TVXKPPJg0rtp7h87TSqLPCmowLxrfdy8XbbwPG06WT33jEo9uxlkvcQN17tAmVy8h72yPEdMFLz4Ewo7BPs2va35eLynScI8WpV2PENW2bwQBSa9lSufu32+wTwl4MU8vohfvRyT07ylCIe8dHHPPPg+ST0Ooag8EsIiO9F7w7ylM0Y7dfgOPADaPLwX7hq7iG8xPDW9Lb1Q8oU98twTPYDUvTomwIQ8akcfvUhXkj3mK6Q8syXxvAMb+DwfMI87bsGYPGUbJ71GHtS8XbbwvFQ+P70f14+7Uq+CPSXgxbvHfFK9icgwPQsEbbwm60O9EpRiPDjTKb3uFJm7p/BCPazDuzxh+iy8Xj2wvBqrl71a7nU9guq5PYNDOb1X2Pk8raD5u+bSpLsMD2u7C9ktPVS6gDzyjhI9vl2gPNO0AT0/vJ68XQTyvMMCWbubYhU9rzK3vLhRaToSlOK6qYIAvQAovrsa1la8CEdwPKOkCT1jEKm8Y7epvOv+HLsoJII704ZBPXbVTDubjVQ8aRnfOvspBr2imYs8MDi2vPFVVDxSrwK9hac2PYverLyxGnO9nqNQvfVLD71UEP+8tDDvurN+8Lzkbqc6tsKsu5WvXTtDKxo72b03PdDshryvXfY81JE/vLYbLL2Fp7Y7JbUGPEQ2GLyagla7fAxDPaVhhrxu7Ne7wzAZPOxXHDx5nUe9s35wPHcOizx1fM26FTGePAsEbbzzQBE9zCQMPW6TWDygucy8zPZLPM2oSjzfmy48EF4lvUttDj3NL4q8WIp4PRoEFzxKFA89uKpou9H3BDvK6009a33cPLq15rzv8VY9AQX8O1gxebzjCqo7EeJjPaA1DrxoZ2C65tIkvS0iOjxln2W8o0sKPMPXGb3Ak908cxhQvR8wDzzN1gq8DnNovMZGFbwUJiA9moJWPBl9VzkVA148TrlHO/nFCL1f7y68xe2VPIROtzvCJRu88YMUvaUzRj1qR5+7e6jFPGyrHL3/SgC9GMtYPJcT27yqMX688YOUO32+QT18iAS9cdeUPFbN+zvlx6a83d6xOzQLL7sZJNi8mSnXOuqan7uqin09CievvPw0hLyuq/c866Udu4T1t7wBXnu7zQFKvE5gyDxhUyw8qzx8vIrTLr0Kq+26TgdJPWmVoDzOiIk8aDwhPVug9Lq6iie9iSEwvOKxqjwMiyy7E59gPepMnjth+iw9ntGQOyDijbw76SW9i96sO7qKJ7ybYhU8R/6Su+GmLLzsgtu7inovPRG3pLwZUpi7YzvoucrAjjwOSKm8uuOmvLbt67wKUu68XCc0vbd0Kz0LXWy8lHmgPAAoPjxRpAS99oHMvOlBoDprUh09teLtOxoEl7z0mRA89tpLvVQQ/zyjdkk9ZZ/lvHLikrw76SW82LI5vXyIBLzVnL06NyGrPPXPzTta7nW8FTEePSVcB73FGFU9SFcSPbzL4rtXrbo84lirvcd8Urw9/yG9+63EvPdhCz2rPPw8PPQjvbXibbuo+0C8oWtLPWVG5juL3qw71Zw9PMUY1Tk3yKu8WWq3vLnYKL25A+i8zH2LvMW/1bxDr1g8Cqvtu3pPRr0FrbU8vVKiO0LSGj1b+fM7Why2ux1FUjwhv0s89lYNPUbFVLzJ4M88t/hpvdpvNj0EzfY7gC29u0HyW7yv2Tc8dSPOvNhZurzrpR28jUIqPM0vijxyDdK8iBYyvZ0fkrxalXa9JeBFPO/GF71dBHK8X8FuPKnY/jpQmQY9S5jNPGBz7TrpQaA87/FWvUHyWzwCEPq78HiWOhfuGr0ltYY9I/iJPamCgLwLBO28jZupu38ivzuIbzG8Cfnuu0dMlLypKQG7BzxyvR5QULwCEHo8k8ehPUXoFjzPvka9MDi2vPsphjwjfMi854QjvcW/VbzO4Yg7Li04vL/h3jsaL9a5iG8xuybrwzz3YYu8Gw8VvVGkBD1UugA99MRPuCjLArzvxhc8XICzPFyrcr0gDU296h7eu8jV0TxNKos8lSufuqT9CD1oDmE8sqGyu2PiaLz6osY5YjBqPBAFJrwIlfG8PlihOBE74zzzQJG8r112vJPHobyrPPw7YawrPb5doLqtzrk7qHcCPVIoQzz5l0i81UM+vFd/eryaVxc9xA3XO/6YgbweJZG7W840PF0Ecj19ZUI8x1GTOtb1vDyDnLg8yxkOvOywGz0kqgg8fTqDvKlUQL3Bnlu992ELvZPHobybCZa82LK5vf2NgzwnnUK8YMzsPKOkiTxDr9g6la/duz3/IbusR/q8lmFcvFbN+zztCRu95nklPVKBwjwEJnY6V9j5PPK50bz6okY7R6UTPPnFiDwCafk8N8grO/gTCr1iiWm8AhB6vHHXlLyV3Z08vtZgPMDsXDsck9O7mdBXvRLCojzkbqe8XxpuvDSyLzu0MO87cxhQvd3eMbxtDxo9JKqIvB8CT72zrDC7s37wPHvWhbuXQZs8UlYDu7ef6rzsV5y8IkYLvUo/Tjz+R/88PrGgujSyrzxsBJy8P7yeO7f46byfKpA8cFDVPLygIzsdGpO77LCbvLSJ7rtgzOy7sA91O0hXkrwhO408XKvyvMUYVT2mPsQ8d+DKu9lkuLy+iF89xZSWPJFjpDwIlfE8bC9bPBE7Y7z/+f08W6B0PAc8crhmquO7RvOUPDybJLwlXAe9cuKSvMPXGbxK5s48sZY0O+4UmT1/Ij+8oNyOvPIH07tNKos8yTnPO2RpKDwRO+O7vl2gvKSvB7xGmpW7nD9TPZpXFzyXQRs9InHKurhR6bwb4VS8iiwuO3pPxrxeD3A8CfluO//OPr0MaOq8r112vAwP6zynHgM9T+cHPJuNVLzLRE07EmkjvWHX6rzBGh285G4nPe6Y17sCafm8//n9PJkpVzv9P4K7IWbMPCtlvTxHKVK8JNXHO/uCBblAFZ48xyPTvGaqY7wXlRs9EDDlPHcOizyNQiq9W3W1O7iq6LxwqdQ69MRPvSJGC7n3CIy8HOxSvSjLAryU0p87QJncvEoUjzsi7Qu9U4xAOwn5brzfm668Wu71uu002rw/Y588o6SJPFfY+Tyfg4+8u5WlPMDBnTzVnD08ljadu3sBxbzfm668n4OPO9VDvrz0mZC8kFimPNiyOT134Mo8vquhvDA4Njyjz0i7zVpJu1rudbwmksQ794xKuhN0ITz/zj68Vvu7unBQ1bv8NAS97FecOyxwOzs1ZC68AIG9PKLyCryvtvU8ntEQPBkkWD2xwfO7QfLbOhqIVTykVog7lSufvKOkiTwpqEA9/RFCvKxHejx3tYu74woqPMS0VzoMtuu8ViZ7PL8PH72+L2C81JE/vN3eMTwoywK9z5OHOx4lkTwGBrW8c5QRu4khMDyvBPc8nR8SvdlkuLw0si+9S8aNvCkBwLsXwFo7Od4nPbo8pryp2P68GfkYPKpfvjrsV5w6zuEIvbHB8zxnMSM9C9mtu1nj97zjYym8XFJzPAiVcTyNm6m7X5YvPJ8qED1l+OS8WTx3vGKJ6bt+F0G9jk2oPAR0dzwIR/A8umdlvNLUwjzI1dE7yuvNvBdnW7zdhTI9xkaVPCVcB70Mtus7G7aVPDchK7xuwRi8oDWOu/SZkLxOuUe8c5QRPLBo9Dz/+f07zS+KvNBFBr1n2CO8TKNLO4ZZNbym5US5HsyRvGi1YTwxnDO71vW8PM3WCr3E4he816e7O7QFML2asBa8jZspPSVcBzvjvCi9ZGmoPHV8zbyyobK830KvOgw9q7xzZtG7R6WTPMpnjzxj4mg8mrAWPS+GN7xoZ2C8tsKsOVMIAj1fli89Zc0lO00qCzz+R/87XKvyvLxy4zy52Cg9YjBqvW9F1zybjVS8mwmWvLvA5DymugU9DOQrPJWvXbvT38C8TrnHvLbt67sgiQ49e32GPPTETzv7goW7cKnUOoOcuLpG85S8CoCuO7ef6rkaqxe90tTCPJ8qkDvuuxk8FFFfPK9ddrtAbh08roC4PAnOrztV8D08jemquwR09ziL3iy7xkaVumVG5rygNQ69CfnuPGBzbTyE9Tc9Z9ijPK8yNzxgoa084woqu1F2RLwN76m7hrI0vf7xgLwaXRY6JmeFO68ytzrrpR29XbZwPYI4uzvkFai8qHcCPRCJ5DxKFI+7dHHPPE65xzxvnta8BPs2vWaq4zwrvjy8tDDvvEq7D7076SU9q+N8PAsyLTxb+XM9xZQWPP7ufzxsXZu6BEk4vGXNJbwBXvu8xA3XO8lcEbuuJzk8GEeavGnun7sMPSs9ITsNu1yr8roj+Ik8To6IvKjQgbwIwzG8wqlZvDfIK7xln2W8B+Pyu1HPw7sBjDs9Ba01PGSU57w/Yx867FecPFdUu7w2b6w7X5avvA8l57ypKQE9oGBNPeyC27vGytM828i1PP9KAD2/4V68eZ1HvDHqtDvR94Q6UwgCPLMlcbz+w0C8HwJPu/I1k7yZ/pe8aLXhPHYDDT28oKO8p2wEvdVDvrxh+qy8WDF5vJBYpjpaR3U8vgQhPNItwrsJoG88UaQEu3e1C7yagtY6HOzSOw9+5ryYTBk9q+N8POMKqrwoywI9DLZrPCN8SDxYivi8b3MXPf/OvruvBHc8M6exvA3vKbxz7RA8Fdieu4rTrrwFVDa8Vvu7PF0Ecjs6N6e8BzzyPP/Ovrv2rww9t59qvEoUDz3HUZO7UJkGPRigmbz/+X28qjH+u3jACbxlzaW7DA9rvFLawbwLBO2547yoO1t1NTr1pI68Vs37PAI+Ojx8s8O8xnHUvPg+yTwLBO26ybUQPfUoTTw76SU8i96sPKWMRbwUqt46pj7EPGX4ZL3ILtG8AV77vM0BSjzKZ488CByxvIWnNjyIFrI83CwzPN2FsjzHUZO8rzK3O+iPIbyGCzQ98NGVuxpdlrxhrKs8hQC2vFWXvjsCaXm8oRJMPHyIBLz+HMA8W/nzvHkZCb0pqMC87m0YPCu+vDsM5Ks8VnR8vG0Pmrt0yk48y3KNvKcegzwGMXS9xZQWPDYWrTxxAtQ7IWZMPU4Hybw89CO8/eaCPPMSUTxuk9i8WAY6vGfYozsQMGW8Li24vI+mJzxKFI88HwJPPFru9btRz8O6L9+2u29F1zwC5bq7RGHXvMtyjbr5bIm7V626uxsPlTv1KE29UB3FPMwkDDupggC8SQkRvH4XQT1cJ7Q8nvzPvKsRvTu9+SI8JbUGuiP4iTx460i99JkQPNF7Qz26Dma8u+4kvHO/0LyzfvA8EIlkPUPdmLpmUWS8uxnku8f4E72ruL27BzxyvKeXwz1plSC8gpG6vEQ2mLvtYho91Zy9vLvA5DtnXGK7sZY0uyu+PLwXlZu8GquXvE2uSb0ezBG8wn6au470KD1Abh28YMzsvPQdT7xKP867Xg/wO81aSb0IarK7SY1PO5EKJTsMi6y8cH4VvcXtlbwdGhM8xTsXPQvZLbxgzOw7Pf8hPRsPlbzDMJm8ZGmoPM1aSb0HEbO8PPQjvX5wwDwQXiW9wlDaO7SJ7jxFE9a8FTEePG5omTvPkwc8vtZgux9bzrmwD3W8U2EBPAVUNj0hlIw7comTPAEF/DvKwI68YKGtPJ78Tz1boHQ9sOS1vHiSSTlVG307HsyRPHEwFDxQmQY8CaBvvB0aE70PfuY8+neHvHOUET3ssBu7+tCGPJl3WDx4wAk9d1yMPOqanzwGBjW8ZialPB7MEby1O+07J0RDu4yQq7xpGV88ZXQmPc3WCruRCqU8Xbbwu+0JG7kXGVq8SY1PvKblxDv/oH68r7Z1OynWgDklh0a8E/hfPBCJZL31/Y08sD21vA9+Zjy6DmY82WQ4PAJp+TxHTJQ8JKoIvUBunbwgDc26BzxyvVUb/bz+w8A8Wu51u8guUbyHZLM8Iu0LvJqCVj3nhKO96kwevVDyBb3UDYG79zNLO7KhMj1IgtE83NOzO0f+krw89CM9z5OHuz+OXj2TxyE8wOzcPP91v7zUZgA8DyVnvILqOTzn3aI8j/+mO8xPyzt1UQ48+R4IvQnOrzt1I067QtKau9vINb1+7AE8sA/1uy7UOLzpQSC8dqoNPSnWgDsJoO+8ANo8vfDRlbwefpC89wgMPI1CKrrYsrm78mBSvFFLBb1Pa0a8s1MxPHbVzLw+WCG9kbyjvNt6tLwfMA+8HwLPvGO3qTyyobK8DcFpPInIsLwXGdq7nBSUPGdc4ryTx6G8T+eHPBxolDvIqhK8rqv3u1fY+Tz3M0s9qNCBO/GDlL2N6Sq9XKtyPFMIgrw0Cy+7Y7epPLJzcrz/+X28la/du8MC2bwTn+C5YSXsvDneJzz/SoC8H9ePvHMY0Lx0nw+9lSsfvS3Jujz/SgC94rEqvQwP67zd3rE83NOzPKvj/DyYmpo8h2SzvF8abjye0ZC8vSRivCKfijs/vJ48NAuvvFIoQzzFGFU9dtVMPa2g+TtpGd88Uv2DO3kZiTwA2rw79f2Nu1ugdDx0nw+8di7MvIrTrjz08g+8j6anvGH6LLxQ8oW8LBc8Pf0/Ajxl+OQ8SQkRPYrTrrzyNRM8GquXu9ItQjz1Sw87C9mtuxXYnrwDl7m87Y1ZO2ChrbyhQIy4EsIiPWpHHz0inwo7teJtPJ0fEroHPPK7fp4APV/B7rwwODa8L4Y3OiaSxLsBBfw7RI8XvP5H/zxVlz68n1VPvEBuHbwTzSA8fOEDvV49sDs2b6y8mf6XPMVm1jvjvCg8ETvjPEQ2GLxK5s47Q92YuxOfYLyod4K8EDDlPHAlFj1zGFC8pWGGPE65R7wBMzy8nJjSvLoO5rwwkbU7Eu3hvLOsMDyyobI6YHNtPKs8fLzXp7s6AV57PV49MLsVMR68+4KFPIkhMLxeaG87mXdYulyAMzzQRQY9ljadu3YDDby7GWS7phOFPEJ5mzq6tea6Eu1hPJjzmTz+R388di5MvJn+F7wi7Qs8K768PFnj9zu5MSi8Gl2WvJfomzxHd1O8vw8fvONjqbxuaBk980ARPSNRiTwLMi272Fk6vDGcs7z60Ia8vX1hOzvppbuKLK48jZspvZkpV7pWJns7G7YVPdPfwLyruL08FFHfu7ZprbwT+N84+1TFPGpHn7y9JOI8xe2Vu08SR7zs29o8/RFCPCbAhDzfQi89OpCmvL194boeJZE8kQqlvES6VjrzEtE7eGeKu2kZX71rfdw8D6wmu6Y+xLzJXJE8DnPovJrbVbvkFai8KX0Bvfr7RbuXbNq8Gw+VPRCJ5LyA1D28uQPoPLygo7xENpi8/RHCvEOv2DwRtyS9o0uKPNshNbvmeSU8IyPJvCedQjy7GWQ8Wkf1vGKJ6bztYho8vHLju5cT2zzKZw+88jWTvFb7uznYCzm8" + }, + { + "object": "embedding", + "index": 1, + "embedding": "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" + } + ], + "model": "text-embedding-3-small", + "usage": { + "prompt_tokens": 9, + "total_tokens": 9 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IEmbeddingGenerator> generator = new EmbeddingsClient(new("http://somewhere"), new AzureKeyCredential("key"), new() + { + Transport = new HttpClientTransport(httpClient), + }).AsIEmbeddingGenerator("text-embedding-3-small"); + + var response = await generator.GenerateAsync([ + "hello, world!", + "red, white, blue", + ]); + Assert.That(response, Is.Not.Null); + Assert.That(response.Count, Is.EqualTo(2)); + + Assert.That(response.Usage, Is.Not.Null); + Assert.That(response.Usage!.InputTokenCount, Is.EqualTo(9)); + Assert.That(response.Usage.TotalTokenCount, Is.EqualTo(9)); + + foreach (Embedding e in response) + { + Assert.That(e.ModelId, Is.EqualTo("text-embedding-3-small")); + Assert.That(e.CreatedAt, Is.Not.Null); + Assert.That(e.Vector.Length, Is.EqualTo(1536)); + Assert.That(e.Vector.ToArray(), Has.Some.Not.EqualTo(0f)); + } + } +} diff --git a/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs new file mode 100644 index 000000000000..f8f062ff2cc0 --- /dev/null +++ b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Azure; +using Azure.AI.Inference; +using Azure.Core.Pipeline; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using NUnit.Framework; + +namespace Microsoft.Extensions.AI; + +public class AzureAIInferenceImageEmbeddingGeneratorTests +{ + [Test] + public void AsIEmbeddingGenerator_InvalidArgs_Throws() + { + var ex = Assert.Throws(() => ((ImageEmbeddingsClient)null!).AsIEmbeddingGenerator()); + Assert.That(ex!.ParamName, Is.EqualTo("imageEmbeddingsClient")); + + ImageEmbeddingsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); + var ex2 = Assert.Throws(() => client.AsIEmbeddingGenerator(" ")); + Assert.That(ex2!.ParamName, Is.EqualTo("defaultModelId")); + + client.AsIEmbeddingGenerator(null); + } + + [Test] + public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + ImageEmbeddingsClient client = new(endpoint, new AzureKeyCredential("key")); + + IEmbeddingGenerator> embeddingGenerator = client.AsIEmbeddingGenerator(model); + var metadata = embeddingGenerator.GetService(); + Assert.That(metadata?.ProviderName, Is.EqualTo("az.ai.inference")); + Assert.That(metadata?.ProviderUri, Is.EqualTo(endpoint)); + Assert.That(metadata?.DefaultModelId, Is.EqualTo(model)); + } + + [Test] + public void GetService_SuccessfullyReturnsUnderlyingClient() + { + var client = new ImageEmbeddingsClient(new("http://somewhere"), new AzureKeyCredential("key")); + var embeddingGenerator = client.AsIEmbeddingGenerator("model"); + + Assert.That(embeddingGenerator.GetService>>(), Is.SameAs(embeddingGenerator)); + Assert.That(embeddingGenerator.GetService(), Is.SameAs(client)); + + using IEmbeddingGenerator> pipeline = embeddingGenerator + .AsBuilder() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Build(); + + Assert.That(pipeline.GetService>>(), Is.Not.Null); + Assert.That(pipeline.GetService>>(), Is.Not.Null); + Assert.That(pipeline.GetService>>(), Is.Not.Null); + + Assert.That(pipeline.GetService(), Is.SameAs(client)); + Assert.That(pipeline.GetService>>(), Is.TypeOf>>()); + } + + [Test] + public async Task GenerateAsync_ExpectedRequestResponse() + { + DataContent dotnetPng = new(GetImageDataUri()); + + const string Input = """ + { + "input":[{"image":"data:image/png;base64,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"}], + "encoding_format":"base64", + "model":"embed-v-4-0" + } + """; + + const string Output = """ + { + "id":"9da7c0f0-4b9d-46d9-9323-f10f46977493", + "object":"list", + "data": + [ + { + "index":0, + "object":"embedding", + "embedding": "AADkPAAA6bsAAD68AAAnOwAAYbwAAFa8AABfvQAAqzsAAGy8AABwvAAAyDwAALo9AAC6vAAAID0AAGE8AAA2PAAA4TsAABU9AAC0PAAAqzwAADw8AAAaPQAA2LwAANa8AACoOgAA4DsAAA48AAC9PAAAdz0AAIC8AACGvAAA+7oAAEo7AABMuwAAab0AAFc9AAA2OwAAob0AAPO8AABGOwAAoLwAAKQ6AACHvAAAX70AAJQ8AAA/OwAAtbwAAME7AADMvAAA2DwAABQ9AABZPQAAd7wAAD+9AAAquwAAgTwAABE9AABFPQAALbwAAEk9AAC8ugAABj0AAI27AAAWPQAAMrwAAE88AABnvAAAZbwAAMK8AAAhPQAAPr0AAAg8AABcOgAA/jwAANE8AACvvAAAEbwAALy8AAAIvQAAe7wAAGW8AAAPPQAAFTsAALc5AACrOwAAirwAALa8AACLvAAACLsAABa7AAC+uAAAljwAAMS9AAC0vAAAhz0AAJw7AAAgvQAAxjwAAMK8AACMPAAAdz0AALC8AADIPAAArjsAAGG8AAATvAAAkrsAALs8AAAWPAAAxDwAADK9AAAAvQAAmTwAAIK9AADZPAAAmjwAABG9AAARuwAA/zwAAGO8AAC3PAAAGTwAACC8AAAtPAAAArwAAGG7AAC4PAAA/7sAAKG8AACdOwAA8DwAAJo7AAC8PAAAST0AAAI8AABnvAAAXTwAABc9AACSPAAAMjsAAPc7AABSvAAATLsAAKa8AAB1PAAAA70AAC87AAASvAAA/DwAADC7AABfvAAAYbwAAGW9AADlOwAANzwAAFc8AADEPAAAyrsAAMM8AAATPQAA3DwAABu8AAB+uQAAKj0AADS7AACkOwAAhD0AACK8AABIvQAAaboAALu8AADtOwAAoDwAAI88AACQPAAAmjwAAEy8AAC2OwAAtTwAAE68AACGvQAA0LsAAJM7AAAUvQAA17wAAEg9AABhOwAAVjwAALg8AACHvQAA5DwAACI9AACLPAAA4zsAAOk7AADOPAAA/7wAAPe8AAAGPQAAYTwAAEo9AAA/PAAA47wAAEq8AADgvAAAybsAAPk8AAA7vQAA3zsAAP87AAAUvAAAKjwAAKA8AAATvQAAcrwAAGm7AADlvAAAprwAAJM7AACivAAABr0AAEu7AAAxuwAAjD0AAMu8AAA7vQAATjwAADo8AADfOgAAFboAACA9AAA2OwAAZDwAAOo7AABBvAAAKzsAAJK8AAC7vAAAFL0AAO47AAADvQAARj0AAJS8AADLuwAA5bwAAKa7AAAGPQAA8bwAAKG9AAC/vAAADrwAAKO8AACDOgAAr7wAABM9AAD0uwAAQr0AABs9AAC2PAAAOLwAAM88AADSPAAAqzsAAOm7AABMPAAAGz0AAHU8AAAAvQAAULwAAAa9AADavAAAgzoAAKk8AABVvAAAPboAAHU8AACKvAAAgbwAADI8AAALuwAAIb0AAKi8AABxvAAAUz0AAJk8AAAnvQAA3zwAAMM7AAAVPAAA0bwAAME6AADCuwAAIrwAAMs8AACbPAAArbwAAGG8AAChOwAAEL0AAIQ7AADePAAADr0AADE9AAAbvQAAprwAAK+8AAARvAAAWrwAAL+8AAALPAAAWTwAAJ86AACGOwAAU70AACm8AAAJPQAA+LwAAKC8AABtvAAAtLwAALQ7AACmvAAAAj0AALW8AAA0PQAAhjwAAEa6AAAfPQAAirwAAOa7AABDPAAAqLwAANM8AAAGvQAA4DsAANO7AAAdvAAA7TwAACM7AACfvAAASDsAABs8AACxPAAAVzwAAEy9AAAxPAAAmb0AALw8AAAZvAAAiLwAALY8AAB3vAAA9zwAAJs8AAAkvAAAOz0AAMo7AABLPQAAwbsAAN47AABGuQAAl7oAAG08AACJvAAAZ7oAALw8AACavAAA37wAAKA7AAAgvAAANb0AAGA8AAAhPQAANz0AAMq7AADGvAAAlTwAABI9AABhuwAAkbsAAIY7AADauwAAtDwAABk8AAD7PAAAiDwAAPG8AACwvQAAn70AAFI8AACqugAAn7wAAGA9AAA0vQAAmrwAACo9AACCOwAAoTsAAIE9AABwOwAAAr0AANc8AAAbvAAAjDwAABe9AAAPvQAA07wAACG8AADBOwAAeDwAAAg9AAB0PAAAm7wAAEW6AACaugAADr0AANY8AAD1vAAA5zsAAKK9AAAXOwAAPr0AAAA9AAD3uwAAG7wAABW9AAAOPQAAMrwAAIA7AADdPAAAEb0AAGM8AAAjvQAAUDoAABI9AAD/PAAAHL0AAKM8AACbOQAAlbwAAAO9AACqPAAAAr0AAIy7AACCvAAAZjwAAGO8AAC9PQAA7DsAAJ88AAByOwAAmrsAAD+8AAArvAAA37wAAPo8AAAkvAAAL7sAACO9AAAnvQAA9DwAAJY8AACxPAAAeTwAAFO8AAAFvQAAHzwAADe7AACmPAAAKD0AAHM9AAAgvAAAmrwAALy8AAC/OwAA3LwAAG06AAAfOwAA/7sAALE9AADBPAAAtrsAAKI8AACZuwAAgrwAAES9AADcuwAAsjsAAJE8AABWPAAAK70AAEU8AABEPAAAMbwAAK+7AACcvAAARLsAABK6AAAiPAAAEbwAANG7AAChPAAAzzwAAMs6AAAFPQAA2TsAACG9AAB1PAAAsrwAAC29AABMPAAAzzwAANI8AADfvAAAm7wAAC29AACLuwAAHTwAALq8AAAcuwAA07wAAHm8AACxvAAA7LwAAK06AAA4PQAA7LsAAKC7AAAvuwAAKrwAAC68AABtPAAAtjwAAC+8AAAJvQAATLwAALE7AACCvAAApjwAAKE8AAC4vAAAjDwAACS9AAD3PAAAHz4AACe9AAB7vQAAET0AAII6AAC2OwAAyzwAANY7AAB+PQAAuDwAAME8AADMugAAAjwAANA7AAAgvAAAFT4AAPe7AAAPvQAALrwAAJQ5AAArOwAAFjwAAKe8AAD4uwAAGTwAACQ8AAAJPAAAZTwAAJa8AACgOwAANjsAAJk8AAC7OwAAdzwAAPG7AACfvAAAtjwAAFq8AAAMPQAAMDwAAHu9AAC6vAAAVT0AAKo7AACOPAAAoTsAANc8AAAXPQAAbDwAAKi7AABVOgAA5zwAAHU8AADCvAAAyjwAAAa9AADqOwAAmbwAALq7AAA+vAAAjDwAAB+9AAAqvQAAir0AAFo9AAA+PQAAgrsAANM8AAAhPAAAhbwAAAU8AACavAAAuLwAAKa8AACqPAAAI7wAAHG8AABFPAAAgToAAIy8AAAkuwAAjrwAAA49AACpPAAACz0AABC9AAAbvAAAWjwAAPI7AAAoPAAAJjoAAK26AAAXOwAADzwAAC+9AAC4vAAAIL0AAIk8AABhPAAAPj0AAHI7AAAUvQAALT0AACG8AAByPAAADD0AANk8AAC/vAAA4bwAAGu8AAC1vAAA0jsAALc8AAChvAAAT7wAAMu8AACOvAAA4bwAAHg8AAD1PAAACz0AAB08AAAXPAAAPr0AAIG6AAAFuwAAKTwAAI27AABPPAAAmzsAAOC8AAAbPQAAp7oAAGq8AABdOgAAzDwAANe8AAAdvQAALjoAABU9AAATPQAA0rsAAAc9AAD7PAAATLwAALA6AAAruwAAX7sAABK9AAC7PAAAErwAACG8AAC3OgAAkzwAAMw7AAAEOwAAqjwAAEW7AAAHPQAA6rsAAES8AACCPQAARj0AAGY8AABGPAAAdLwAANE8AAD1vAAAGzsAAEQ6AACuuwAAFb0AAIE8AAA4PAAAlbsAAH68AAACOwAAsjwAAKE8AAAoPAAAhDsAAME8AAD7uwAAkr0AAFq9AAC4OwAAsjwAADA8AACCvAAAbbwAAAs9AACWvAAAEzwAALS8AAAgPQAAd7wAAO42AABWvQAAHLwAAPG6AAAAPAAAFz0AAME7AAAoOwAAULsAANo8AABRuwAAiDwAABw8AADVuwAA+rsAAAo9AAAavAAAMDwAANe8AAD+vAAAibwAAJC8AABfOwAAtTwAAIE8AADmOwAAgLwAAMS8AABwPAAAAb0AALS8AAAqvQAANDwAAOU8AACWvAAAzjwAABG7AACouwAAJr0AAIM7AAAZvQAA0boAAFi8AABPPAAAnzgAAIE8AACbvAAAFb0AABY8AAC+OwAA5DwAAJa6AACkPAAAITwAAGE6AABtPAAAMb0AADg9AAAEPQAAnDwAAJ08AAABPAAAursAAHc8AAAFvAAA5ToAAD28AAAAPAAAazwAADQ7AADqPAAAA70AAFO7AAA6vAAAAj4AAEg6AADhPAAAELwAAFm9AACIvAAAxTwAACQ8AADkOwAAbrwAALq7AACGPAAAIL0AAGE8AADMPAAAOr0AACM9AACMPAAAKrsAAAY8AAAhPAAAKz0AACe9AACOvAAAa7wAACG9AABKuwAASrwAAI+8AAApvQAA+LsAAPe8AADGuwAAgroAADe7AACvuwAATz0AAMQ6AACFvAAAMLwAACg9AAADvQAAtTwAALa8AACuPAAAI70AAJI8AAAauwAAZbwAAA89AADWvAAAqDwAAAm9AAAAPQAAEDwAAOA8AAAxvQAAYzwAAB87AADhOgAAwrsAAOA8AAA3vQAA1jwAAKi8AAB1uwAAGb0AAJo8AABmPAAAPLwAAMI7AAC4PAAAmj0AAFc8AADcOgAAe7wAAH47AABdOwAAlrwAAPO8AAB5PQAAijsAABU8AAAOvQAAkTwAABK8AAC4PAAAZLwAAK68AACRvAAAwzwAAKq8AABWvQAA4DsAAKC8AACUPAAAm7wAAJO8AAAMuQAAwrsAAAk8AABdvQAAkrwAACQ8AAAoNwAApDwAABQ8AAAVPAAAH7wAAFK8AAAGPAAAkrsAAIA8AADGPAAAbrwAALc8AABxPAAApDwAABy8AAAZPQAAk7wAAMW8AABhvAAAPLwAAEI8AAB5PAAAxrwAAFi7AADwvAAAUL0AAAk9AABZOwAAED0AALY8AAB5PAAAmzwAAFM9AAAwPQAAsToAAPA6AADOvAAAMLsAAHO8AADQuAAAqLwAANc7AAA4PAAA3DsAAK48AAAdPAAAH7wAACQ7AAD5OwAAo7sAACY8AACrPAAATzwAAL68AAC9PAAA8DwAABI7AADeOwAAFL0AAAC9AACEOwAAITsAAJI8AADtuwAA8LsAANa8AACvvAAAI70AAAG9AABmOwAAd7wAAIE8AAA6vQAAvzwAAEK9AAD0vAAA/zwAAPU8AACVPAAAET0AAAU7AAAfOwAANroAAKm8AAAUvQAAyLsAAAa9AAAUvAAAErwAAII7AAAFPQAAALsAAC08AAA0uwAAgTwAAIu7AADRvAAADzwAAKA7AABDvAAAirsAALo8AAB3vAAAOLwAACO9AADEPAAA7jwAADg9AAAiPQAAqzcAANA8AAAuPAAAODwAAAW8AACNvAAAIjwAANC8AAAmvQAAoTwAAAc9AACHvAAABjsAAI68AADZPAAAobsAAIi9AADsvAAABrsAAAm8AABkOwAACDwAAIY8AABQvAAAmTwAABE9AAAFvQAABzwAAF08AACoPAAAzjwAAL49AAAfPAAAkbwAALQ6AAByvAAAcD0AAN+6AACTvAAAkDsAAK66AAC0PAAAkzoAAHy8AAAiOwAADDwAAIG9AAAmvAAACrsAADU9AAAjuAAAjbwAAPc8AACNOwAABbwAAMG7AACIvAAAO7wAAL88AAD7vAAAXLwAADw4AAC2PAAAnbsAADs7AAAwvAAA0LwAAPG8AAAmPQAAz7oAAOa8AABhuwAA+jwAAFU8AADLuwAAtzwAAHA8AAA3vAAAdbwAAIG8AAC6PAAAiDkAANi7AADpuQAALrsAAL09AABauwAAMbwAAOG8AAA2OgAAejsAAGY8AAB/uwAACTsAADa7AAAGvAAASrwAAKG8AAC2OgAA3LoAABy8AACiPAAACD0AAPy8AACyvAAAIDsAAIi7AACwvAAA6rwAAMy8AAA0vQAALr0AAKS7AABgPAAASbwAAA69AAAnvQAApLwAAIE8AACUOgAAYbwAABo7AACfPAAADr0AACg9AAAAvAAAFzwAAIM7AAABOwAAujwAABS9AABqvAAAHLwAAHg8AAB3PQAAQ7wAAB08AAAIPQAAhLwAAHq8AAAfPQAAljwAAME7AAChOwAA5jgAAAy7AAALPAAAv7wAAA08AAC+uwAAzDwAAAQ9AACoPAAANTwAANi8AAAPPQAABj0AAM68AAB7uwAAIz0AAB29AAATuwAAjbsAAJ88AACfOwAAAj0AAHi8AAA9vAAAYbwAAMo8AADpPAAAAbwAABU7AAAgPAAA+jsAAAm8AABgPAAAIb0AAIK9AABwPAAAtzwAAFi7AAAmPAAAozwAAFW9AAAwvAAAFT0AAJm8AADjvAAAEjsAAFI8AAACvQAANrwAAEm7AACLuwAAITwAABu8AAD4uwAAyLwAAFw8AAA2PAAAVTwAANW8AADDPAAAMLwAACC7AADMPAAARTsAAA28AABkPAAArjwAADI8AAAEvQAAujsAAFY8AABavAAA9zwAAKI8AABVPAAA+7sAAOC8AACFPQAAjTsAAKg8AACpuwAAsjsAABU7AABRPAAAHL0AAEY8AAAhPAAAerwAAKS7AAAXOwAAkLsAAAA9AAAxPAAA4TwAACi8AADYOwAAu7sAAF68AABLPAAATL0AAEK9AADwuwAAjDsAADW6AACEPAAAv7wAAJa8AABQPQAAfLwAAAe8AAC9PAAAnTsAABM8AADQvAAAcjwAAP86AAA2vQAAKD0AAMQ8AADevAAAobwAAGE8AAB7PAAAjzwAAIY8AACkPAAA2joAAKY8AAAGPQAAc7sAALw8AAABPAAAebwAAAs9AAAoOwAAmjsAAH48AABZPAAAAjwAAIm9AAAGvQAAFTwAACo7AACLvAAArrwAAJS6AADnugAABj0AAAu8AADcvAAAvbwAAKE5AADePAAAqbwAAOw8AAA2vQAA7ToAAIG7AAA2vQAAC70AACk8AACIPAAAFr0AAKe6AAAZvQAArzwAAG48AABsPAAAAbsAAD89AACnPAAAAb0AAOu8AAAQPQAA5TwAALg8AAAbOwAAWbwAAJu7AAAJPAAA4TwAABm9AAD0OgAA07wAAPe7AAB/uwAAED0AADs8AADEOgAAhrsAAJM8AABLvQAAq7wAAL06AACfPAAAlDwAAIY9AABavAAAjDoAAAG9AABlPAAAjLsAALK8AADaPAAA4LsAAPA8AABMvAAAXTsAAOG6AAD6OgAAvjkAANC8AABxuwAAybsAAK+7AABsOwAA5zwAABW8AAC9PAAAiDwAAPg8AAAJOwAAATsAADs8AAAdPQAAeTsAACK8AADrvAAASTsAAKM8AADMPAAAU70AABK8AAD0uwAA0rwAAF08AAChNwAAbbwAAB28AACZPAAAlLwAAME8AABmvAAAhjsAAPA9AADSvAAABTwAAP48AAAVvAAAdzwAADY8AACGPQAA2DwAAC07AAC0ugAAwTsAAJC8AACdPAAAajwAAKE7AAAiPQAAzjsAAA69AACdvAAAIr0AAEi9AADBOgAAgLsAANU8AACpPAAAP7wAAPq8AAAfPAAACTwAAC49AABhPQAAsjwAAMy7AAB0PQAABb0AAAy9AAAhvQAAWL0AAHy8AAAjPQAAjDwAAGC8AACbvAAADT0AAK08AACivAAAF7wAAL+8AACTPAAAz7wAAPw7AABfvAAAt7wAALi8AAAvPQAAtrsAAJY7AAAKPQAAr7wAACS9AAC8PAAAm7wAALa8AADBvAAA3zsAAIk8AABmOwAAw7wAAPm7AAArPAAAvzsAAF+8AABPuwAAXzwAAK+8AAA3PQAAG7wAAIg8AAAXvAAAprwAADA8AADEvAAAorwAANa8AABePQAAJr0AACG8AAAcvAAAQ70AAPC8AACxPAAAOLsAAOc6AABYPAAAsLwAAN68AACXuwAALbwAAJu7AAD7uwAA2jsAALY7AACWPAAAoLwAALa8AACwuwAA/DsAAEy8AAAiPAAA5bwAAGk8AABnPAAADzwAAF27AAAGOwAAtrsAAIS7AAAqPQAAeLwAAAa9" + } + ], + "model":"embed-v4.0", + "usage": + { + "prompt_tokens":1012, + "completion_tokens":0, + "total_tokens":1012 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IEmbeddingGenerator> generator = new ImageEmbeddingsClient( + new("https://somwhere"), new AzureKeyCredential("key"), new() + { + Transport = new HttpClientTransport(httpClient), + }).AsIEmbeddingGenerator("embed-v-4-0"); + + var response = await generator.GenerateAsync([dotnetPng]); + Assert.That(response, Is.Not.Null); + Assert.That(response.Count, Is.EqualTo(1)); + + Assert.That(response.Usage, Is.Not.Null); + Assert.That(response.Usage!.InputTokenCount, Is.EqualTo(1012)); + Assert.That(response.Usage.TotalTokenCount, Is.EqualTo(1012)); + + foreach (Embedding e in response) + { + Assert.That(e.ModelId, Is.EqualTo("embed-v4.0")); + Assert.That(e.CreatedAt, Is.Not.Null); + Assert.That(e.Vector.Length, Is.EqualTo(1536)); + Assert.That(e.Vector.ToArray(), Has.Some.Not.EqualTo(0)); + } + } + + private Uri GetImageDataUri() + { + using Stream? s = GetType().Assembly.GetManifestResourceStream("Azure.AI.Inference.Tests.Data.juggling_balls.png"); + Assert.That(s, Is.Not.Null); + using MemoryStream ms = new(); + s!.CopyTo(ms); + return new Uri($"data:image/png;base64,{Convert.ToBase64String(ms.ToArray())}"); + } +} diff --git a/sdk/ai/Azure.AI.Inference/tests/Utilities/VerbatimHttpHandler.cs b/sdk/ai/Azure.AI.Inference/tests/Utilities/VerbatimHttpHandler.cs new file mode 100644 index 000000000000..8744495ef46a --- /dev/null +++ b/sdk/ai/Azure.AI.Inference/tests/Utilities/VerbatimHttpHandler.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Microsoft.Extensions.AI; + +/// +/// An that checks the request body against an expected one +/// and sends back an expected response. +/// +public sealed class VerbatimHttpHandler(string expectedInput, string expectedOutput, bool validateExpectedResponse = false) : + DelegatingHandler(new HttpClientHandler()) +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Assert.IsNotNull(request.Content); + + string? actualInput = await request.Content!.ReadAsStringAsync().ConfigureAwait(false); + + Assert.IsNotNull(actualInput); + AssertEqualNormalized(expectedInput, actualInput); + + if (validateExpectedResponse) + { + ByteArrayContent newContent = new(Encoding.UTF8.GetBytes(actualInput)); + foreach (var header in request.Content.Headers) + { + newContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + request.Content = newContent; + + using var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + string? actualOutput = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + Assert.IsNotNull(actualOutput); + AssertEqualNormalized(expectedOutput, actualOutput); + } + + return new() { Content = new StringContent(expectedOutput) }; + } + + public static string? RemoveWhiteSpace(string? text) => + text is null ? null : + Regex.Replace(text, @"\s*", string.Empty); + + private static void AssertEqualNormalized(string expected, string actual) + { + // First try to compare as JSON. + JsonNode? expectedNode = null; + JsonNode? actualNode = null; + try + { + expectedNode = JsonNode.Parse(expected); + actualNode = JsonNode.Parse(actual); + } + catch + { + } + + if (expectedNode is not null && actualNode is not null) + { + if (!JsonNode.DeepEquals(expectedNode, actualNode)) + { + FailNotEqual(expected, actual); + } + + return; + } + + // Legitimately may not have been JSON. Fall back to whitespace normalization. + if (RemoveWhiteSpace(expected) != RemoveWhiteSpace(actual)) + { + FailNotEqual(expected, actual); + } + } + + private static void FailNotEqual(string expected, string actual) => + Assert.Fail( + $"Expected:{Environment.NewLine}" + + $"{expected}{Environment.NewLine}" + + $"Actual:{Environment.NewLine}" + + $"{actual}"); +} From 40dbd38da97e8800fad846c60c522a45ea1114ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Thu, 15 May 2025 11:27:58 -0500 Subject: [PATCH 3/3] Use [RecordedTest] attribute to align with existing tests --- .../tests/AzureAIInferenceChatClientTests.cs | 42 ++++++++++--------- ...AzureAIInferenceEmbeddingGeneratorTests.cs | 9 ++-- ...AIInferenceImageEmbeddingGeneratorTests.cs | 9 ++-- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceChatClientTests.cs b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceChatClientTests.cs index cbe9524e8f13..d345c323485e 100644 --- a/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceChatClientTests.cs +++ b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceChatClientTests.cs @@ -14,6 +14,7 @@ using Azure; using Azure.AI.Inference; using Azure.Core.Pipeline; +using Azure.Core.TestFramework; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using NUnit.Framework; @@ -23,7 +24,7 @@ namespace Microsoft.Extensions.AI; public class AzureAIInferenceChatClientTests { - [Test] + [RecordedTest] public void AsIChatClient_InvalidArgs_Throws() { var ex = Assert.Throws(() => ((ChatCompletionsClient)null!).AsIChatClient("model")); @@ -34,7 +35,7 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.That(ex2!.ParamName, Is.EqualTo("defaultModelId")); } - [Test] + [RecordedTest] public void NullModel_Throws() { ChatCompletionsClient client = new(new("http://localhost/some/endpoint"), new AzureKeyCredential("key")); @@ -47,7 +48,7 @@ public void NullModel_Throws() Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("hello", new ChatOptions { ModelId = null }).GetAsyncEnumerator().MoveNextAsync().AsTask()); } - [Test] + [RecordedTest] public void AsIChatClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); @@ -62,7 +63,7 @@ public void AsIChatClient_ProducesExpectedMetadata() Assert.That(metadata?.DefaultModelId, Is.EqualTo(model)); } - [Test] + [RecordedTest] public void GetService_SuccessfullyReturnsUnderlyingClient() { ChatCompletionsClient client = new(new("http://localhost"), new AzureKeyCredential("key")); @@ -133,6 +134,7 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() } """; + [RecordedTest] [TestCase(false)] [TestCase(true)] public async Task BasicRequestResponse_NonStreaming(bool multiContent) @@ -205,6 +207,7 @@ [new ChatMessage(ChatRole.User, "hello".Select(c => (AIContent)new TextContent(c """; + [RecordedTest] [TestCase(false)] [TestCase(true)] public async Task BasicRequestResponse_Streaming(bool multiContent) @@ -243,7 +246,7 @@ [new ChatMessage(ChatRole.User, "hello".Select(c => (AIContent)new TextContent(c } } - [Test] + [RecordedTest] public async Task IChatClient_WithNullModel_ChatOptions_WithNotNullModel_NonStreaming() { using VerbatimHttpHandler handler = new(BasicInputNonStreaming, BasicOutputNonStreaming); @@ -260,7 +263,7 @@ public async Task IChatClient_WithNullModel_ChatOptions_WithNotNullModel_NonStre Assert.That(response.Text, Is.EqualTo("Hello! How can I assist you today?")); } - [Test] + [RecordedTest] public async Task IChatClient_WithNullModel_ChatOptions_WithNotNullModel_Streaming() { using VerbatimHttpHandler handler = new(BasicInputStreaming, BasicOutputStreaming); @@ -281,7 +284,7 @@ public async Task IChatClient_WithNullModel_ChatOptions_WithNotNullModel_Streami Assert.That(responseText, Is.EqualTo("Hello! How can I assist you today?")); } - [Test] + [RecordedTest] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { const string Input = """ @@ -369,7 +372,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio Assert.That(response.Text, Is.EqualTo("Hello! How can I assist you today?")); } - [Test] + [RecordedTest] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_Streaming() { const string Input = """ @@ -457,7 +460,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio Assert.That(responseText, Is.EqualTo("Hello! How can I assist you today?")); } - [Test] + [RecordedTest] public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStreaming() { const string Input = """ @@ -536,7 +539,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr Assert.That(response.Text, Is.EqualTo("Hello! How can I assist you today?")); } - [Test] + [RecordedTest] public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Streaming() { const string Input = """ @@ -641,7 +644,7 @@ private sealed class AzureAIChatToolJson public Dictionary Properties { get; set; } = []; } - [Test] + [RecordedTest] public async Task AdditionalOptions_NonStreaming() { const string Input = """ @@ -700,7 +703,7 @@ public async Task AdditionalOptions_NonStreaming() }), Is.Not.Null); } - [Test] + [RecordedTest] public async Task TopK_DoNotOverwrite_NonStreaming() { const string Input = """ @@ -760,7 +763,7 @@ public async Task TopK_DoNotOverwrite_NonStreaming() }), Is.Not.Null); } - [Test] + [RecordedTest] public async Task ResponseFormat_Text_NonStreaming() { const string Input = """ @@ -796,7 +799,7 @@ public async Task ResponseFormat_Text_NonStreaming() }), Is.Not.Null); } - [Test] + [RecordedTest] public async Task ResponseFormat_Json_NonStreaming() { const string Input = """ @@ -832,7 +835,7 @@ public async Task ResponseFormat_Json_NonStreaming() }), Is.Not.Null); } - [Test] + [RecordedTest] public async Task ResponseFormat_JsonSchema_NonStreaming() { const string Input = """ @@ -899,7 +902,7 @@ public async Task ResponseFormat_JsonSchema_NonStreaming() }), Is.Not.Null); } - [Test] + [RecordedTest] public async Task MultipleMessages_NonStreaming() { const string Input = """ @@ -1015,7 +1018,7 @@ public async Task MultipleMessages_NonStreaming() Assert.That(response.Usage.TotalTokenCount, Is.EqualTo(57)); } - [Test] + [RecordedTest] public async Task MultipleContent_NonStreaming() { const string Input = """ @@ -1070,7 +1073,7 @@ public async Task MultipleContent_NonStreaming() ])]), Is.Not.Null); } - [Test] + [RecordedTest] public async Task NullAssistantText_ContentEmpty_NonStreaming() { const string Input = """ @@ -1157,6 +1160,7 @@ public static IEnumerable FunctionCallContent_NonStreaming_MemberData( yield return new object[] { ChatToolMode.RequireSpecific("GetPersonAge") }; } + [RecordedTest] [TestCaseSource(nameof(FunctionCallContent_NonStreaming_MemberData))] public async Task FunctionCallContent_NonStreaming(ChatToolMode mode) { @@ -1270,7 +1274,7 @@ public async Task FunctionCallContent_NonStreaming(ChatToolMode mode) AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); } - [Test] + [RecordedTest] public async Task FunctionCallContent_Streaming() { const string Input = """ diff --git a/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceEmbeddingGeneratorTests.cs b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceEmbeddingGeneratorTests.cs index 302267170e75..0be7a662f228 100644 --- a/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceEmbeddingGeneratorTests.cs +++ b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceEmbeddingGeneratorTests.cs @@ -9,6 +9,7 @@ using Azure; using Azure.AI.Inference; using Azure.Core.Pipeline; +using Azure.Core.TestFramework; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using NUnit.Framework; @@ -17,7 +18,7 @@ namespace Microsoft.Extensions.AI; public class AzureAIInferenceEmbeddingGeneratorTests { - [Test] + [RecordedTest] public void AsIEmbeddingGenerator_InvalidArgs_Throws() { var ex = Assert.Throws(() => ((EmbeddingsClient)null!).AsIEmbeddingGenerator()); @@ -30,7 +31,7 @@ public void AsIEmbeddingGenerator_InvalidArgs_Throws() client.AsIEmbeddingGenerator(null); } - [Test] + [RecordedTest] public void AsIEmbeddingGenerator_AzureAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); @@ -45,7 +46,7 @@ public void AsIEmbeddingGenerator_AzureAIClient_ProducesExpectedMetadata() Assert.That(metadata?.DefaultModelId, Is.EqualTo(model)); } - [Test] + [RecordedTest] public void GetService_SuccessfullyReturnsUnderlyingClient() { var client = new EmbeddingsClient(new("http://somewhere"), new AzureKeyCredential("key")); @@ -68,7 +69,7 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.That(pipeline.GetService>>(), Is.TypeOf>>()); } - [Test] + [RecordedTest] public async Task GenerateAsync_ExpectedRequestResponse() { const string Input = """ diff --git a/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs index f8f062ff2cc0..a5cc24b8d8bb 100644 --- a/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs +++ b/sdk/ai/Azure.AI.Inference/tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs @@ -10,6 +10,7 @@ using Azure; using Azure.AI.Inference; using Azure.Core.Pipeline; +using Azure.Core.TestFramework; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using NUnit.Framework; @@ -18,7 +19,7 @@ namespace Microsoft.Extensions.AI; public class AzureAIInferenceImageEmbeddingGeneratorTests { - [Test] + [RecordedTest] public void AsIEmbeddingGenerator_InvalidArgs_Throws() { var ex = Assert.Throws(() => ((ImageEmbeddingsClient)null!).AsIEmbeddingGenerator()); @@ -31,7 +32,7 @@ public void AsIEmbeddingGenerator_InvalidArgs_Throws() client.AsIEmbeddingGenerator(null); } - [Test] + [RecordedTest] public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); @@ -46,7 +47,7 @@ public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() Assert.That(metadata?.DefaultModelId, Is.EqualTo(model)); } - [Test] + [RecordedTest] public void GetService_SuccessfullyReturnsUnderlyingClient() { var client = new ImageEmbeddingsClient(new("http://somewhere"), new AzureKeyCredential("key")); @@ -69,7 +70,7 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.That(pipeline.GetService>>(), Is.TypeOf>>()); } - [Test] + [RecordedTest] public async Task GenerateAsync_ExpectedRequestResponse() { DataContent dotnetPng = new(GetImageDataUri());