diff --git a/eng/MSBuild/Shared.props b/eng/MSBuild/Shared.props index dee583f7e39..23b183e6484 100644 --- a/eng/MSBuild/Shared.props +++ b/eng/MSBuild/Shared.props @@ -14,7 +14,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs index 29fd405b947..6895d4c1e42 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -7,11 +7,9 @@ namespace Microsoft.Extensions.AI; /// Provides a base class for all content used with AI services. [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] -[JsonDerivedType(typeof(AudioContent), typeDiscriminator: "audio")] [JsonDerivedType(typeof(DataContent), typeDiscriminator: "data")] [JsonDerivedType(typeof(FunctionCallContent), typeDiscriminator: "functionCall")] [JsonDerivedType(typeof(FunctionResultContent), typeDiscriminator: "functionResult")] -[JsonDerivedType(typeof(ImageContent), typeDiscriminator: "image")] [JsonDerivedType(typeof(TextContent), typeDiscriminator: "text")] [JsonDerivedType(typeof(UsageContent), typeDiscriminator: "usage")] public class AIContent diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AudioContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AudioContent.cs deleted file mode 100644 index 356cce78413..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AudioContent.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents audio content. -/// -public class AudioContent : DataContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The URI of the content. This can be a data URI. - /// The media type (also known as MIME type) represented by the content. - public AudioContent(Uri uri, string? mediaType = null) - : base(uri, mediaType) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The URI of the content. This can be a data URI. - /// The media type (also known as MIME type) represented by the content. - [JsonConstructor] - public AudioContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null) - : base(uri, mediaType) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The byte contents. - /// The media type (also known as MIME type) represented by the content. - public AudioContent(ReadOnlyMemory data, string? mediaType = null) - : base(data, mediaType) - { - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs index 39d610a6dcb..1e158a8801b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -105,6 +105,14 @@ public DataContent(ReadOnlyMemory data, string? mediaType = null) _data = data; } + /// + /// Determines whether the has the specified prefix. + /// + /// The media type prefix. + /// if the has the specified prefix, otherwise . + public bool MediaTypeStartsWith(string prefix) + => MediaType?.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) is true; + /// Sets to null if it's empty or composed entirely of whitespace. private static void ValidateMediaType(ref string? mediaType) { @@ -172,6 +180,7 @@ public string Uri /// as the instance actually contains all of the data it represents. If, however, the instance was constructed from another form of URI, one /// that simply references where the data can be found but doesn't actually contain the data, this property returns . /// + [MemberNotNullWhen(true, nameof(Data))] [JsonIgnore] public bool ContainsData => _dataUri is not null || _data is not null; @@ -180,7 +189,6 @@ public string Uri /// If is , this property returns the represented data. /// If is , this property returns . /// - [MemberNotNullWhen(true, nameof(ContainsData))] [JsonIgnore] public ReadOnlyMemory? Data { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageContent.cs deleted file mode 100644 index df559152412..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageContent.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents image content. -/// -public class ImageContent : DataContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The URI of the content. This can be a data URI. - /// The media type (also known as MIME type) represented by the content. - public ImageContent(Uri uri, string? mediaType = null) - : base(uri, mediaType) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The URI of the content. This can be a data URI. - /// The media type (also known as MIME type) represented by the content. - [JsonConstructor] - public ImageContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null) - : base(uri, mediaType) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The byte contents. - /// The media type (also known as MIME type) represented by the content. - public ImageContent(ReadOnlyMemory data, string? mediaType = null) - : base(data, mediaType) - { - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 1ec1225a9dc..85903beb701 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -484,12 +484,16 @@ private static List GetContentParts(IList con parts.Add(new ChatMessageTextContentItem(textContent.Text)); break; - case ImageContent imageContent when imageContent.Data is { IsEmpty: false } data: - parts.Add(new ChatMessageImageContentItem(BinaryData.FromBytes(data), imageContent.MediaType)); - break; + case DataContent dataContent when dataContent.MediaTypeStartsWith("image/"): + if (dataContent.ContainsData) + { + parts.Add(new ChatMessageImageContentItem(BinaryData.FromBytes(dataContent.Data.Value), dataContent.MediaType)); + } + else if (dataContent.Uri is string uri) + { + parts.Add(new ChatMessageImageContentItem(new Uri(uri))); + } - case ImageContent imageContent when imageContent.Uri is string uri: - parts.Add(new ChatMessageImageContentItem(new Uri(uri))); break; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj index 919fa9b751f..b3f00b85912 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj @@ -26,7 +26,7 @@ true true - + @@ -37,5 +37,5 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj index f80630ceeb5..bd7e8d27201 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj @@ -40,5 +40,5 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 6bdd924b87a..46cbb7d7d11 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -386,74 +386,76 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe OllamaChatRequestMessage? currentTextMessage = null; foreach (var item in content.Contents) { - if (currentTextMessage is not null && item is not ImageContent) + if (item is DataContent { ContainsData: true } dataContent && dataContent.MediaTypeStartsWith("image/")) { - yield return currentTextMessage; - currentTextMessage = null; - } - - switch (item) - { - case TextContent textContent: - currentTextMessage = new OllamaChatRequestMessage - { - Role = content.Role.Value, - Content = textContent.Text ?? string.Empty, - }; - break; - - case ImageContent imageContent when imageContent.Data is not null: - IList images = currentTextMessage?.Images ?? []; - images.Add(Convert.ToBase64String(imageContent.Data.Value + IList images = currentTextMessage?.Images ?? []; + images.Add(Convert.ToBase64String(dataContent.Data.Value #if NET - .Span)); + .Span)); #else - .ToArray())); + .ToArray())); #endif - if (currentTextMessage is not null) - { - currentTextMessage.Images = images; - } - else + if (currentTextMessage is not null) + { + currentTextMessage.Images = images; + } + else + { + yield return new OllamaChatRequestMessage { - yield return new OllamaChatRequestMessage + Role = content.Role.Value, + Images = images, + }; + } + } + else + { + if (currentTextMessage is not null) + { + yield return currentTextMessage; + currentTextMessage = null; + } + + switch (item) + { + case TextContent textContent: + currentTextMessage = new OllamaChatRequestMessage { Role = content.Role.Value, - Images = images, + Content = textContent.Text ?? string.Empty, }; - } + break; - break; - - case FunctionCallContent fcc: - { - yield return new OllamaChatRequestMessage + case FunctionCallContent fcc: { - Role = "assistant", - Content = JsonSerializer.Serialize(new OllamaFunctionCallContent + yield return new OllamaChatRequestMessage { - CallId = fcc.CallId, - Name = fcc.Name, - Arguments = JsonSerializer.SerializeToElement(fcc.Arguments, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(IDictionary))), - }, JsonContext.Default.OllamaFunctionCallContent) - }; - break; - } + Role = "assistant", + Content = JsonSerializer.Serialize(new OllamaFunctionCallContent + { + CallId = fcc.CallId, + Name = fcc.Name, + Arguments = JsonSerializer.SerializeToElement(fcc.Arguments, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(IDictionary))), + }, JsonContext.Default.OllamaFunctionCallContent) + }; + break; + } - case FunctionResultContent frc: - { - JsonElement jsonResult = JsonSerializer.SerializeToElement(frc.Result, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); - yield return new OllamaChatRequestMessage + case FunctionResultContent frc: { - Role = "tool", - Content = JsonSerializer.Serialize(new OllamaFunctionResultContent + JsonElement jsonResult = JsonSerializer.SerializeToElement(frc.Result, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); + yield return new OllamaChatRequestMessage { - CallId = frc.CallId, - Result = jsonResult, - }, JsonContext.Default.OllamaFunctionResultContent) - }; - break; + Role = "tool", + Content = JsonSerializer.Serialize(new OllamaFunctionResultContent + { + CallId = frc.CallId, + Result = jsonResult, + }, JsonContext.Default.OllamaFunctionResultContent) + }; + break; + } } } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj index a6d3b013c0d..34541c55783 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj @@ -38,5 +38,5 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs index 239e2add074..0cc75c5f34f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs @@ -536,10 +536,10 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => } else if (contentPart.Kind == ChatMessageContentPartKind.Image) { - ImageContent? imageContent; + DataContent? imageContent; aiContent = imageContent = - contentPart.ImageUri is not null ? new ImageContent(contentPart.ImageUri, contentPart.ImageBytesMediaType) : - contentPart.ImageBytes is not null ? new ImageContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : + contentPart.ImageUri is not null ? new DataContent(contentPart.ImageUri, contentPart.ImageBytesMediaType) : + contentPart.ImageBytes is not null ? new DataContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : null; if (imageContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs index e8193df24d5..96a71b83650 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs @@ -185,7 +185,7 @@ public static IEnumerable FromOpenAIChatMessages(IEnumerable FromOpenAIChatContent(IList openAiMessageContentParts) { - List contents = new(); + List contents = []; foreach (var openAiContentPart in openAiMessageContentParts) { switch (openAiContentPart.Kind) @@ -194,14 +194,13 @@ private static List FromOpenAIChatContent(IList ToOpenAIChatContent(IList parts.Add(ChatMessageContentPart.CreateTextPart(textContent.Text)); break; - case ImageContent imageContent when imageContent.Data is { IsEmpty: false } data: - parts.Add(ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(data), imageContent.MediaType)); - break; + case DataContent dataContent when dataContent.MediaTypeStartsWith("image/"): + if (dataContent.ContainsData) + { + parts.Add(ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(dataContent.Data.Value), dataContent.MediaType)); + } + else if (dataContent.Uri is string uri) + { + parts.Add(ChatMessageContentPart.CreateImagePart(new Uri(uri))); + } - case ImageContent imageContent when imageContent.Uri is string uri: - parts.Add(ChatMessageContentPart.CreateImagePart(new Uri(uri))); break; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs index 15134782bd7..7ff4d781c98 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs @@ -239,7 +239,7 @@ public void ToStreamingChatCompletionUpdates_MultiChoice() new ChatMessage(ChatRole.Assistant, [ new TextContent("Hello, "), - new ImageContent("http://localhost/image.png"), + new DataContent("http://localhost/image.png", mediaType: "image/png"), new TextContent("world!"), ]) { @@ -275,7 +275,7 @@ public void ToStreamingChatCompletionUpdates_MultiChoice() Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt); Assert.Equal("assistant", update0.Role?.Value); Assert.Equal("Hello, ", Assert.IsType(update0.Contents[0]).Text); - Assert.IsType(update0.Contents[1]); + Assert.Equal("image/png", Assert.IsType(update0.Contents[1]).MediaType); Assert.Equal("world!", Assert.IsType(update0.Contents[2]).Text); Assert.Equal("choice1Value", update0.AdditionalProperties?["choice1Key"]); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs index d1325b89bb7..17ccb373e75 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs @@ -124,8 +124,8 @@ public void Text_GetSet_UsesFirstTextContent() { ChatMessage message = new(ChatRole.User, [ - new AudioContent("http://localhost/audio"), - new ImageContent("http://localhost/image"), + new DataContent("http://localhost/audio"), + new DataContent("http://localhost/image"), new FunctionCallContent("callId1", "fc1"), new TextContent("text-1"), new TextContent("text-2"), @@ -163,8 +163,8 @@ public void Text_Set_AddsTextMessageToListWithNoText() { ChatMessage message = new(ChatRole.User, [ - new AudioContent("http://localhost/audio"), - new ImageContent("http://localhost/image"), + new DataContent("http://localhost/audio"), + new DataContent("http://localhost/image"), new FunctionCallContent("callId1", "fc1"), ]); Assert.Equal(3, message.Contents.Count); @@ -265,7 +265,7 @@ public void ItCanBeSerializeAndDeserialized() { AdditionalProperties = new() { ["metadata-key-1"] = "metadata-value-1" } }, - new ImageContent(new Uri("https://fake-random-test-host:123"), "mime-type/2") + new DataContent(new Uri("https://fake-random-test-host:123"), "mime-type/2") { AdditionalProperties = new() { ["metadata-key-2"] = "metadata-value-2" } }, @@ -273,18 +273,10 @@ public void ItCanBeSerializeAndDeserialized() { AdditionalProperties = new() { ["metadata-key-3"] = "metadata-value-3" } }, - new AudioContent(new BinaryData(new[] { 3, 2, 1 }, options: TestJsonSerializerContext.Default.Options), "mime-type/4") + new TextContent("content-4") { AdditionalProperties = new() { ["metadata-key-4"] = "metadata-value-4" } }, - new ImageContent(new BinaryData(new[] { 2, 1, 3 }, options: TestJsonSerializerContext.Default.Options), "mime-type/5") - { - AdditionalProperties = new() { ["metadata-key-5"] = "metadata-value-5" } - }, - new TextContent("content-6") - { - AdditionalProperties = new() { ["metadata-key-6"] = "metadata-value-6" } - }, new FunctionCallContent("function-id", "plugin-name-function-name", new Dictionary { ["parameter"] = "argument" }), new FunctionResultContent("function-id", "plugin-name-function-name", "function-result"), ]; @@ -292,7 +284,7 @@ public void ItCanBeSerializeAndDeserialized() // Act var chatMessageJson = JsonSerializer.Serialize(new ChatMessage(ChatRole.User, contents: items) { - Text = "content-1-override", // Override the content of the first text content item that has the "content-1" content + Text = "content-1-override", // Override the content of the first text content item that has the "content-1" content AuthorName = "Fred", AdditionalProperties = new() { ["message-metadata-key-1"] = "message-metadata-value-1" }, }, TestJsonSerializerContext.Default.Options); @@ -316,15 +308,15 @@ public void ItCanBeSerializeAndDeserialized() Assert.Single(textContent.AdditionalProperties); Assert.Equal("metadata-value-1", textContent.AdditionalProperties["metadata-key-1"]?.ToString()); - var imageContent = deserializedMessage.Contents[1] as ImageContent; - Assert.NotNull(imageContent); - Assert.Equal("https://fake-random-test-host:123/", imageContent.Uri); - Assert.Equal("mime-type/2", imageContent.MediaType); - Assert.NotNull(imageContent.AdditionalProperties); - Assert.Single(imageContent.AdditionalProperties); - Assert.Equal("metadata-value-2", imageContent.AdditionalProperties["metadata-key-2"]?.ToString()); + var dataContent = deserializedMessage.Contents[1] as DataContent; + Assert.NotNull(dataContent); + Assert.Equal("https://fake-random-test-host:123/", dataContent.Uri); + Assert.Equal("mime-type/2", dataContent.MediaType); + Assert.NotNull(dataContent.AdditionalProperties); + Assert.Single(dataContent.AdditionalProperties); + Assert.Equal("metadata-value-2", dataContent.AdditionalProperties["metadata-key-2"]?.ToString()); - var dataContent = deserializedMessage.Contents[2] as DataContent; + dataContent = deserializedMessage.Contents[2] as DataContent; Assert.NotNull(dataContent); Assert.True(dataContent.Data!.Value.Span.SequenceEqual(new BinaryData(new[] { 1, 2, 3 }, TestJsonSerializerContext.Default.Options))); Assert.Equal("mime-type/3", dataContent.MediaType); @@ -332,30 +324,14 @@ public void ItCanBeSerializeAndDeserialized() Assert.Single(dataContent.AdditionalProperties); Assert.Equal("metadata-value-3", dataContent.AdditionalProperties["metadata-key-3"]?.ToString()); - var audioContent = deserializedMessage.Contents[3] as AudioContent; - Assert.NotNull(audioContent); - Assert.True(audioContent.Data!.Value.Span.SequenceEqual(new BinaryData(new[] { 3, 2, 1 }, TestJsonSerializerContext.Default.Options))); - Assert.Equal("mime-type/4", audioContent.MediaType); - Assert.NotNull(audioContent.AdditionalProperties); - Assert.Single(audioContent.AdditionalProperties); - Assert.Equal("metadata-value-4", audioContent.AdditionalProperties["metadata-key-4"]?.ToString()); - - imageContent = deserializedMessage.Contents[4] as ImageContent; - Assert.NotNull(imageContent); - Assert.True(imageContent.Data?.Span.SequenceEqual(new BinaryData(new[] { 2, 1, 3 }, TestJsonSerializerContext.Default.Options))); - Assert.Equal("mime-type/5", imageContent.MediaType); - Assert.NotNull(imageContent.AdditionalProperties); - Assert.Single(imageContent.AdditionalProperties); - Assert.Equal("metadata-value-5", imageContent.AdditionalProperties["metadata-key-5"]?.ToString()); - - textContent = deserializedMessage.Contents[5] as TextContent; + textContent = deserializedMessage.Contents[3] as TextContent; Assert.NotNull(textContent); - Assert.Equal("content-6", textContent.Text); + Assert.Equal("content-4", textContent.Text); Assert.NotNull(textContent.AdditionalProperties); Assert.Single(textContent.AdditionalProperties); - Assert.Equal("metadata-value-6", textContent.AdditionalProperties["metadata-key-6"]?.ToString()); + Assert.Equal("metadata-value-4", textContent.AdditionalProperties["metadata-key-4"]?.ToString()); - var functionCallContent = deserializedMessage.Contents[6] as FunctionCallContent; + var functionCallContent = deserializedMessage.Contents[4] as FunctionCallContent; Assert.NotNull(functionCallContent); Assert.Equal("plugin-name-function-name", functionCallContent.Name); Assert.Equal("function-id", functionCallContent.CallId); @@ -363,7 +339,7 @@ public void ItCanBeSerializeAndDeserialized() Assert.Single(functionCallContent.Arguments); Assert.Equal("argument", functionCallContent.Arguments["parameter"]?.ToString()); - var functionResultContent = deserializedMessage.Contents[7] as FunctionResultContent; + var functionResultContent = deserializedMessage.Contents[5] as FunctionResultContent; Assert.NotNull(functionResultContent); Assert.Equal("function-result", functionResultContent.Result?.ToString()); Assert.Equal("function-id", functionResultContent.CallId); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs index 33eca7dcaae..9af25dbd16a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs @@ -171,7 +171,7 @@ void AddGap() { for (int i = 0; i < gapLength; i++) { - updates.Add(new() { Contents = [new ImageContent("https://uri")] }); + updates.Add(new() { Contents = [new DataContent("https://uri", mediaType: "image/png")] }); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs index a54ca225a98..371d9c70bad 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs @@ -91,8 +91,8 @@ public void Text_GetSet_UsesFirstTextContent() Role = ChatRole.User, Contents = [ - new AudioContent("http://localhost/audio"), - new ImageContent("http://localhost/image"), + new DataContent("http://localhost/audio"), + new DataContent("http://localhost/image"), new FunctionCallContent("callId1", "fc1"), new TextContent("text-1"), new TextContent("text-2"), @@ -136,8 +136,8 @@ public void Text_Set_AddsTextMessageToListWithNoText() { Contents = [ - new AudioContent("http://localhost/audio"), - new ImageContent("http://localhost/image"), + new DataContent("http://localhost/audio"), + new DataContent("http://localhost/image"), new FunctionCallContent("callId1", "fc1"), ] }; @@ -169,7 +169,7 @@ public void JsonSerialization_Roundtrips() Contents = [ new TextContent("text-1"), - new ImageContent("http://localhost/image"), + new DataContent("http://localhost/image"), new FunctionCallContent("callId1", "fc1"), new DataContent("data"u8.ToArray()), new TextContent("text-2"), @@ -192,8 +192,8 @@ public void JsonSerialization_Roundtrips() Assert.IsType(result.Contents[0]); Assert.Equal("text-1", ((TextContent)result.Contents[0]).Text); - Assert.IsType(result.Contents[1]); - Assert.Equal("http://localhost/image", ((ImageContent)result.Contents[1]).Uri); + Assert.IsType(result.Contents[1]); + Assert.Equal("http://localhost/image", ((DataContent)result.Contents[1]).Uri); Assert.IsType(result.Contents[2]); Assert.Equal("fc1", ((FunctionCallContent)result.Contents[2]).Name); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AudioContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AudioContentTests.cs deleted file mode 100644 index 7aff849e8a1..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AudioContentTests.cs +++ /dev/null @@ -1,6 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -public sealed class AudioContentTests : DataContentTests; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs index 18aae8c0497..dab6e7f3eed 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs @@ -1,6 +1,254 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Text.Json; +using Xunit; + namespace Microsoft.Extensions.AI; -public sealed class DataContentTests : DataContentTests; +public sealed class DataContentTests +{ + [Theory] + + // Invalid URI + [InlineData("", typeof(ArgumentException))] + [InlineData("invalid", typeof(UriFormatException))] + + // Format errors + [InlineData("data", typeof(UriFormatException))] // data missing colon + [InlineData("data:", typeof(UriFormatException))] // data missing comma + [InlineData("data:something,", typeof(UriFormatException))] // mime type without subtype + [InlineData("data:something;else,data", typeof(UriFormatException))] // mime type without subtype + [InlineData("data:type/subtype;;parameter=value;else,", typeof(UriFormatException))] // parameter without value + [InlineData("data:type/subtype;parameter=va=lue;else,", typeof(UriFormatException))] // parameter with multiple = + [InlineData("data:type/subtype;=value;else,", typeof(UriFormatException))] // empty parameter name + [InlineData("data:image/j/peg;base64,/9j/4AAQSkZJRgABAgAAZABkAAD", typeof(UriFormatException))] // multiple slashes in media type + + // Base64 Validation Errors + [InlineData("data:text;base64,something!", typeof(UriFormatException))] // Invalid base64 due to invalid character '!' + [InlineData("data:text/plain;base64,U29tZQ==\t", typeof(UriFormatException))] // Invalid base64 due to tab character + [InlineData("data:text/plain;base64,U29tZQ==\r", typeof(UriFormatException))] // Invalid base64 due to carriage return character + [InlineData("data:text/plain;base64,U29tZQ==\n", typeof(UriFormatException))] // Invalid base64 due to line feed character + [InlineData("data:text/plain;base64,U29t\r\nZQ==", typeof(UriFormatException))] // Invalid base64 due to carriage return and line feed characters + [InlineData("data:text/plain;base64,U29", typeof(UriFormatException))] // Invalid base64 due to missing padding + [InlineData("data:text/plain;base64,U29tZQ", typeof(UriFormatException))] // Invalid base64 due to missing padding + [InlineData("data:text/plain;base64,U29tZQ=", typeof(UriFormatException))] // Invalid base64 due to missing padding + public void Ctor_InvalidUri_Throws(string path, Type exception) + { + Assert.Throws(exception, () => new DataContent(path)); + } + + [Theory] + [InlineData("type")] + [InlineData("type//subtype")] + [InlineData("type/subtype/")] + [InlineData("type/subtype;key=")] + [InlineData("type/subtype;=value")] + [InlineData("type/subtype;key=value;another=")] + public void Ctor_InvalidMediaType_Throws(string type) + { + Assert.Throws("mediaType", () => new DataContent("http://localhost/test", type)); + } + + [Theory] + [InlineData("type/subtype")] + [InlineData("type/subtype;key=value")] + [InlineData("type/subtype;key=value;another=value")] + [InlineData("type/subtype;key=value;another=value;yet_another=value")] + public void Ctor_ValidMediaType_Roundtrips(string mediaType) + { + var content = new DataContent("http://localhost/test", mediaType); + Assert.Equal(mediaType, content.MediaType); + + content = new DataContent("data:,", mediaType); + Assert.Equal(mediaType, content.MediaType); + + content = new DataContent("data:text/plain,", mediaType); + Assert.Equal(mediaType, content.MediaType); + + content = new DataContent(new Uri("data:text/plain,"), mediaType); + Assert.Equal(mediaType, content.MediaType); + + content = new DataContent(new byte[] { 0, 1, 2 }, mediaType); + Assert.Equal(mediaType, content.MediaType); + + content = new DataContent(content.Uri); + Assert.Equal(mediaType, content.MediaType); + } + + [Fact] + public void Ctor_NoMediaType_Roundtrips() + { + DataContent content; + + foreach (string url in new[] { "http://localhost/test", "about:something", "file://c:\\path" }) + { + content = new DataContent(url); + Assert.Equal(url, content.Uri); + Assert.Null(content.MediaType); + Assert.Null(content.Data); + } + + content = new DataContent("data:,something"); + Assert.Equal("data:,something", content.Uri); + Assert.Null(content.MediaType); + Assert.Equal("something"u8.ToArray(), content.Data!.Value.ToArray()); + + content = new DataContent("data:,Hello+%3C%3E"); + Assert.Equal("data:,Hello+%3C%3E", content.Uri); + Assert.Null(content.MediaType); + Assert.Equal("Hello <>"u8.ToArray(), content.Data!.Value.ToArray()); + } + + [Fact] + public void Serialize_MatchesExpectedJson() + { + Assert.Equal( + """{"uri":"data:,"}""", + JsonSerializer.Serialize(new DataContent("data:,"), TestJsonSerializerContext.Default.Options)); + + Assert.Equal( + """{"uri":"http://localhost/"}""", + JsonSerializer.Serialize(new DataContent(new Uri("http://localhost/")), TestJsonSerializerContext.Default.Options)); + + Assert.Equal( + """{"uri":"data:application/octet-stream;base64,AQIDBA==","mediaType":"application/octet-stream"}""", + JsonSerializer.Serialize(new DataContent( + uri: "data:application/octet-stream;base64,AQIDBA=="), TestJsonSerializerContext.Default.Options)); + + Assert.Equal( + """{"uri":"data:application/octet-stream;base64,AQIDBA==","mediaType":"application/octet-stream"}""", + JsonSerializer.Serialize(new DataContent( + new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream"), + TestJsonSerializerContext.Default.Options)); + } + + [Theory] + [InlineData("{}")] + [InlineData("""{ "mediaType":"text/plain" }""")] + public void Deserialize_MissingUriString_Throws(string json) + { + Assert.Throws("uri", () => JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options)!); + } + + [Fact] + public void Deserialize_MatchesExpectedData() + { + // Data + MimeType only + var content = JsonSerializer.Deserialize("""{"mediaType":"application/octet-stream","uri":"data:;base64,AQIDBA=="}""", TestJsonSerializerContext.Default.Options)!; + + Assert.Equal("data:application/octet-stream;base64,AQIDBA==", content.Uri); + Assert.NotNull(content.Data); + Assert.Equal([0x01, 0x02, 0x03, 0x04], content.Data.Value.ToArray()); + Assert.Equal("application/octet-stream", content.MediaType); + Assert.True(content.ContainsData); + + // Uri referenced content-only + content = JsonSerializer.Deserialize("""{"mediaType":"application/octet-stream","uri":"http://localhost/"}""", TestJsonSerializerContext.Default.Options)!; + + Assert.Null(content.Data); + Assert.Equal("http://localhost/", content.Uri); + Assert.Equal("application/octet-stream", content.MediaType); + Assert.False(content.ContainsData); + + // Using extra metadata + content = JsonSerializer.Deserialize(""" + { + "uri": "data:;base64,AQIDBA==", + "modelId": "gpt-4", + "additionalProperties": + { + "key": "value" + }, + "mediaType": "text/plain" + } + """, TestJsonSerializerContext.Default.Options)!; + + Assert.Equal("data:text/plain;base64,AQIDBA==", content.Uri); + Assert.NotNull(content.Data); + Assert.Equal([0x01, 0x02, 0x03, 0x04], content.Data.Value.ToArray()); + Assert.Equal("text/plain", content.MediaType); + Assert.True(content.ContainsData); + Assert.Equal("value", content.AdditionalProperties!["key"]!.ToString()); + } + + [Theory] + [InlineData( + """{"uri": "data:;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType": "text/plain"}""", + """{"uri":"data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType":"text/plain"}""")] + [InlineData( + """{"uri": "data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType": "text/plain"}""", + """{"uri":"data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType":"text/plain"}""")] + [InlineData( // Does not support non-readable content + """{"uri": "data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", "unexpected": true}""", + """{"uri":"data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType":"text/plain"}""")] + [InlineData( // Uri comes before mimetype + """{"mediaType": "text/plain", "uri": "http://localhost/" }""", + """{"uri":"http://localhost/","mediaType":"text/plain"}""")] + public void Serialize_Deserialize_Roundtrips(string serialized, string expectedToString) + { + var content = JsonSerializer.Deserialize(serialized, TestJsonSerializerContext.Default.Options)!; + var reSerialization = JsonSerializer.Serialize(content, TestJsonSerializerContext.Default.Options); + Assert.Equal(expectedToString, reSerialization); + } + + [Theory] + [InlineData("application/json")] + [InlineData("application/octet-stream")] + [InlineData("application/pdf")] + [InlineData("application/xml")] + [InlineData("audio/mpeg")] + [InlineData("audio/ogg")] + [InlineData("audio/wav")] + [InlineData("image/apng")] + [InlineData("image/avif")] + [InlineData("image/bmp")] + [InlineData("image/gif")] + [InlineData("image/jpeg")] + [InlineData("image/png")] + [InlineData("image/svg+xml")] + [InlineData("image/tiff")] + [InlineData("image/webp")] + [InlineData("text/css")] + [InlineData("text/csv")] + [InlineData("text/html")] + [InlineData("text/javascript")] + [InlineData("text/plain")] + [InlineData("text/plain;charset=UTF-8")] + [InlineData("text/xml")] + [InlineData("custom/mediatypethatdoesntexists")] + public void MediaType_Roundtrips(string mediaType) + { + DataContent c = new("data:,", mediaType); + Assert.Equal(mediaType, c.MediaType); + } + + [Theory] + [InlineData("image/gif", "image/")] + [InlineData("IMAGE/JPEG", "image")] + [InlineData("image/vnd.microsoft.icon", "ima")] + [InlineData("image/svg+xml", "IMAGE/")] + [InlineData("image/nonexistentimagemimetype", "IMAGE")] + [InlineData("audio/mpeg", "aUdIo/")] + [InlineData("application/json", "")] + [InlineData("application/pdf", "application/pdf")] + public void HasMediaTypePrefix_ReturnsTrue(string? mediaType, string prefix) + { + var content = new DataContent("http://localhost/image.png", mediaType); + Assert.True(content.MediaTypeStartsWith(prefix)); + } + + [Theory] + [InlineData("audio/mpeg", "image/")] + [InlineData("text/css", "text/csv")] + [InlineData("application/json", "application/json!")] + [InlineData("", "")] // The media type will get normalized to null + [InlineData(null, "image/")] + [InlineData(null, "")] + public void HasMediaTypePrefix_ReturnsFalse(string? mediaType, string prefix) + { + var content = new DataContent("http://localhost/image.png", mediaType); + Assert.False(content.MediaTypeStartsWith(prefix)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs deleted file mode 100644 index 68934ccdba5..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs +++ /dev/null @@ -1,248 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Reflection; -using System.Text.Json; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public abstract class DataContentTests - where T : DataContent -{ - private static T Create(params object?[] args) - { - try - { - return (T)Activator.CreateInstance(typeof(T), args)!; - } - catch (TargetInvocationException e) - { - throw e.InnerException!; - } - } - - public T CreateDataContent(Uri uri, string? mediaType = null) => Create(uri, mediaType)!; - -#pragma warning disable S3997 // String URI overloads should call "System.Uri" overloads - public T CreateDataContent(string uriString, string? mediaType = null) => Create(uriString, mediaType)!; -#pragma warning restore S3997 - - public T CreateDataContent(ReadOnlyMemory data, string? mediaType = null) => Create(data, mediaType)!; - - [Theory] - - // Invalid URI - [InlineData("", typeof(ArgumentException))] - [InlineData("invalid", typeof(UriFormatException))] - - // Format errors - [InlineData("data", typeof(UriFormatException))] // data missing colon - [InlineData("data:", typeof(UriFormatException))] // data missing comma - [InlineData("data:something,", typeof(UriFormatException))] // mime type without subtype - [InlineData("data:something;else,data", typeof(UriFormatException))] // mime type without subtype - [InlineData("data:type/subtype;;parameter=value;else,", typeof(UriFormatException))] // parameter without value - [InlineData("data:type/subtype;parameter=va=lue;else,", typeof(UriFormatException))] // parameter with multiple = - [InlineData("data:type/subtype;=value;else,", typeof(UriFormatException))] // empty parameter name - [InlineData("data:image/j/peg;base64,/9j/4AAQSkZJRgABAgAAZABkAAD", typeof(UriFormatException))] // multiple slashes in media type - - // Base64 Validation Errors - [InlineData("data:text;base64,something!", typeof(UriFormatException))] // Invalid base64 due to invalid character '!' - [InlineData("data:text/plain;base64,U29tZQ==\t", typeof(UriFormatException))] // Invalid base64 due to tab character - [InlineData("data:text/plain;base64,U29tZQ==\r", typeof(UriFormatException))] // Invalid base64 due to carriage return character - [InlineData("data:text/plain;base64,U29tZQ==\n", typeof(UriFormatException))] // Invalid base64 due to line feed character - [InlineData("data:text/plain;base64,U29t\r\nZQ==", typeof(UriFormatException))] // Invalid base64 due to carriage return and line feed characters - [InlineData("data:text/plain;base64,U29", typeof(UriFormatException))] // Invalid base64 due to missing padding - [InlineData("data:text/plain;base64,U29tZQ", typeof(UriFormatException))] // Invalid base64 due to missing padding - [InlineData("data:text/plain;base64,U29tZQ=", typeof(UriFormatException))] // Invalid base64 due to missing padding - public void Ctor_InvalidUri_Throws(string path, Type exception) - { - Assert.Throws(exception, () => CreateDataContent(path)); - } - - [Theory] - [InlineData("type")] - [InlineData("type//subtype")] - [InlineData("type/subtype/")] - [InlineData("type/subtype;key=")] - [InlineData("type/subtype;=value")] - [InlineData("type/subtype;key=value;another=")] - public void Ctor_InvalidMediaType_Throws(string type) - { - Assert.Throws("mediaType", () => CreateDataContent("http://localhost/test", type)); - } - - [Theory] - [InlineData("type/subtype")] - [InlineData("type/subtype;key=value")] - [InlineData("type/subtype;key=value;another=value")] - [InlineData("type/subtype;key=value;another=value;yet_another=value")] - public void Ctor_ValidMediaType_Roundtrips(string mediaType) - { - T content = CreateDataContent("http://localhost/test", mediaType); - Assert.Equal(mediaType, content.MediaType); - - content = CreateDataContent("data:,", mediaType); - Assert.Equal(mediaType, content.MediaType); - - content = CreateDataContent("data:text/plain,", mediaType); - Assert.Equal(mediaType, content.MediaType); - - content = CreateDataContent(new Uri("data:text/plain,"), mediaType); - Assert.Equal(mediaType, content.MediaType); - - content = CreateDataContent(new byte[] { 0, 1, 2 }, mediaType); - Assert.Equal(mediaType, content.MediaType); - - content = CreateDataContent(content.Uri); - Assert.Equal(mediaType, content.MediaType); - } - - [Fact] - public void Ctor_NoMediaType_Roundtrips() - { - T content; - - foreach (string url in new[] { "http://localhost/test", "about:something", "file://c:\\path" }) - { - content = CreateDataContent(url); - Assert.Equal(url, content.Uri); - Assert.Null(content.MediaType); - Assert.Null(content.Data); - } - - content = CreateDataContent("data:,something"); - Assert.Equal("data:,something", content.Uri); - Assert.Null(content.MediaType); - Assert.Equal("something"u8.ToArray(), content.Data!.Value.ToArray()); - - content = CreateDataContent("data:,Hello+%3C%3E"); - Assert.Equal("data:,Hello+%3C%3E", content.Uri); - Assert.Null(content.MediaType); - Assert.Equal("Hello <>"u8.ToArray(), content.Data!.Value.ToArray()); - } - - [Fact] - public void Serialize_MatchesExpectedJson() - { - Assert.Equal( - """{"uri":"data:,"}""", - JsonSerializer.Serialize(CreateDataContent("data:,"), TestJsonSerializerContext.Default.Options)); - - Assert.Equal( - """{"uri":"http://localhost/"}""", - JsonSerializer.Serialize(CreateDataContent(new Uri("http://localhost/")), TestJsonSerializerContext.Default.Options)); - - Assert.Equal( - """{"uri":"data:application/octet-stream;base64,AQIDBA==","mediaType":"application/octet-stream"}""", - JsonSerializer.Serialize(CreateDataContent( - uriString: "data:application/octet-stream;base64,AQIDBA=="), TestJsonSerializerContext.Default.Options)); - - Assert.Equal( - """{"uri":"data:application/octet-stream;base64,AQIDBA==","mediaType":"application/octet-stream"}""", - JsonSerializer.Serialize(CreateDataContent( - new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream"), - TestJsonSerializerContext.Default.Options)); - } - - [Theory] - [InlineData("{}")] - [InlineData("""{ "mediaType":"text/plain" }""")] - public void Deserialize_MissingUriString_Throws(string json) - { - Assert.Throws("uri", () => JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options)!); - } - - [Fact] - public void Deserialize_MatchesExpectedData() - { - // Data + MimeType only - var content = JsonSerializer.Deserialize("""{"mediaType":"application/octet-stream","uri":"data:;base64,AQIDBA=="}""", TestJsonSerializerContext.Default.Options)!; - - Assert.Equal("data:application/octet-stream;base64,AQIDBA==", content.Uri); - Assert.NotNull(content.Data); - Assert.Equal([0x01, 0x02, 0x03, 0x04], content.Data!.Value.ToArray()); - Assert.Equal("application/octet-stream", content.MediaType); - Assert.True(content.ContainsData); - - // Uri referenced content-only - content = JsonSerializer.Deserialize("""{"mediaType":"application/octet-stream","uri":"http://localhost/"}""", TestJsonSerializerContext.Default.Options)!; - - Assert.Null(content.Data); - Assert.Equal("http://localhost/", content.Uri); - Assert.Equal("application/octet-stream", content.MediaType); - Assert.False(content.ContainsData); - - // Using extra metadata - content = JsonSerializer.Deserialize(""" - { - "uri": "data:;base64,AQIDBA==", - "modelId": "gpt-4", - "additionalProperties": - { - "key": "value" - }, - "mediaType": "text/plain" - } - """, TestJsonSerializerContext.Default.Options)!; - - Assert.Equal("data:text/plain;base64,AQIDBA==", content.Uri); - Assert.NotNull(content.Data); - Assert.Equal([0x01, 0x02, 0x03, 0x04], content.Data!.Value.ToArray()); - Assert.Equal("text/plain", content.MediaType); - Assert.True(content.ContainsData); - Assert.Equal("value", content.AdditionalProperties!["key"]!.ToString()); - } - - [Theory] - [InlineData( - """{"uri": "data:;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType": "text/plain"}""", - """{"uri":"data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType":"text/plain"}""")] - [InlineData( - """{"uri": "data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType": "text/plain"}""", - """{"uri":"data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType":"text/plain"}""")] - [InlineData( // Does not support non-readable content - """{"uri": "data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", "unexpected": true}""", - """{"uri":"data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType":"text/plain"}""")] - [InlineData( // Uri comes before mimetype - """{"mediaType": "text/plain", "uri": "http://localhost/" }""", - """{"uri":"http://localhost/","mediaType":"text/plain"}""")] - public void Serialize_Deserialize_Roundtrips(string serialized, string expectedToString) - { - var content = JsonSerializer.Deserialize(serialized, TestJsonSerializerContext.Default.Options)!; - var reSerialization = JsonSerializer.Serialize(content, TestJsonSerializerContext.Default.Options); - Assert.Equal(expectedToString, reSerialization); - } - - [Theory] - [InlineData("application/json")] - [InlineData("application/octet-stream")] - [InlineData("application/pdf")] - [InlineData("application/xml")] - [InlineData("audio/mpeg")] - [InlineData("audio/ogg")] - [InlineData("audio/wav")] - [InlineData("image/apng")] - [InlineData("image/avif")] - [InlineData("image/bmp")] - [InlineData("image/gif")] - [InlineData("image/jpeg")] - [InlineData("image/png")] - [InlineData("image/svg+xml")] - [InlineData("image/tiff")] - [InlineData("image/webp")] - [InlineData("text/css")] - [InlineData("text/csv")] - [InlineData("text/html")] - [InlineData("text/javascript")] - [InlineData("text/plain")] - [InlineData("text/plain;charset=UTF-8")] - [InlineData("text/xml")] - [InlineData("custom/mediatypethatdoesntexists")] - public void MediaType_Roundtrips(string mediaType) - { - DataContent c = new("data:,", mediaType); - Assert.Equal(mediaType, c.MediaType); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ImageContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ImageContentTests.cs deleted file mode 100644 index 7b088e3ebf3..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ImageContentTests.cs +++ /dev/null @@ -1,6 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -public sealed class ImageContentTests : DataContentTests; diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 3797f4b6c47..599b4403fa9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -586,7 +586,7 @@ public async Task MultipleContent_NonStreaming() Assert.NotNull(await client.CompleteAsync([new(ChatRole.User, [ new TextContent("Describe this picture."), - new ImageContent("http://dot.net/someimage.png"), + new DataContent("http://dot.net/someimage.png", mediaType: "image/png"), ])])); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index bff072e1bd4..a9465a8237b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -146,7 +146,7 @@ public virtual async Task MultiModal_DescribeImage() new(ChatRole.User, [ new TextContent("What does this logo say?"), - new ImageContent(GetImageDataUri()), + new DataContent(GetImageDataUri(), "image/png"), ]) ], new() { ModelId = GetModel_MultiModal_DescribeImage() }); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index 5b7bcdddf73..73e231ccbdb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -148,7 +148,7 @@ public async Task FailureUsage_NullJson() [Fact] public async Task FailureUsage_NoJsonInResponse() { - var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, [new ImageContent("https://example.com")])]); + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, [new DataContent("https://example.com")])]); using var client = new TestChatClient { CompleteAsyncCallback = (messages, options, cancellationToken) => Task.FromResult(expectedCompletion),