diff --git a/Directory.Build.targets b/Directory.Build.targets index 816da61d0d8..31c6cd27154 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -8,6 +8,11 @@ + + + false + + $(MSBuildWarningsAsMessages);NETSDK1138;MSB3270 diff --git a/eng/MSBuild/LegacySupport.props b/eng/MSBuild/LegacySupport.props index 7bda63a6607..9e83541b0d8 100644 --- a/eng/MSBuild/LegacySupport.props +++ b/eng/MSBuild/LegacySupport.props @@ -2,7 +2,7 @@ - + @@ -47,10 +47,6 @@ - - - - diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 2219a84d8da..154e91caeb2 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,208 +1,208 @@ - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e1f19886fe3354963a4a790c896b3f99689fd7a5 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 5bae930797f60d2d04f3b1df6a33eaca85fc5f28 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 5bae930797f60d2d04f3b1df6a33eaca85fc5f28 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 5bae930797f60d2d04f3b1df6a33eaca85fc5f28 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 5bae930797f60d2d04f3b1df6a33eaca85fc5f28 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 5bae930797f60d2d04f3b1df6a33eaca85fc5f28 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 5bae930797f60d2d04f3b1df6a33eaca85fc5f28 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 5bae930797f60d2d04f3b1df6a33eaca85fc5f28 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 5bae930797f60d2d04f3b1df6a33eaca85fc5f28 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 5bae930797f60d2d04f3b1df6a33eaca85fc5f28 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 5bae930797f60d2d04f3b1df6a33eaca85fc5f28 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-efcore - 5452ff90a79084afd23df379388ae8bca24284f3 + f55fe13550b5f821336abb63ef5ac454ce4de5fa diff --git a/eng/Versions.props b/eng/Versions.props index b2179c7644b..3c01e6cdc7b 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -10,14 +10,14 @@ - false + true - + release true @@ -33,117 +33,117 @@ --> - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 - 9.0.10 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 - 9.0.10 + 9.0.11 9.0.0-beta.25515.2 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 - 10.0.0-rc.2.25502.107 + 10.0.0 - 10.0.0-rc.2.25502.107 + 10.0.0-beta.25523.111 @@ -168,8 +168,8 @@ 8.0.0 8.0.2 8.0.0 - 8.0.21 - 8.0.21 + 8.0.22 + 8.0.22 8.0.0 8.0.1 8.0.1 @@ -186,18 +186,18 @@ 8.0.6 8.0.0 - 8.0.21 - 8.0.21 - 8.0.21 - 8.0.21 - 8.0.21 - 8.0.21 - 8.0.21 - 8.0.21 - 8.0.21 - 8.0.21 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 - 8.0.21 + 8.0.22 <_ProjectsToBuild Include="$(MSBuildThisFileDirectory)..\src\**\*.csproj" /> diff --git a/eng/packages/General-net10.props b/eng/packages/General-net10.props index 950a6e5e4e6..e40b1c44c90 100644 --- a/eng/packages/General-net10.props +++ b/eng/packages/General-net10.props @@ -38,6 +38,7 @@ + diff --git a/eng/packages/General-net9.props b/eng/packages/General-net9.props index 5820e21763f..fecfa5b84e2 100644 --- a/eng/packages/General-net9.props +++ b/eng/packages/General-net9.props @@ -38,6 +38,7 @@ + diff --git a/eng/packages/General.props b/eng/packages/General.props index ba4dc32e97c..503e2c1c321 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -18,6 +18,7 @@ + @@ -32,7 +33,6 @@ - diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 513f82789d9..47097ab042a 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -31,6 +31,12 @@ + + + $(NoWarn);NU1903 + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md index 47bdf9c4125..e8c2ea1e971 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## NOT YET RELEASED +## 9.10.2 - Updated `AIFunctionFactory` to respect `[DisplayName(...)]` on functions as a way to override the function name. - Updated `AIFunctionFactory` to respect `[DefaultValue(...)]` on function parameters as a way to specify default values. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs index 733b885b39a..e7b535e6995 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs @@ -184,6 +184,47 @@ static async Task ToChatResponseAsync( } } + /// + /// Coalesces image result content elements in the provided list of items. + /// Unlike other content coalescing methods, this will coalesce non-sequential items based on their Name property, + /// and it will replace earlier items with later ones when duplicates are found. + /// + private static void CoalesceImageResultContent(IList contents) + { + Dictionary? imageResultIndexById = null; + bool hasRemovals = false; + + for (int i = 0; i < contents.Count; i++) + { + if (contents[i] is ImageGenerationToolResultContent imageResult && !string.IsNullOrEmpty(imageResult.ImageId)) + { + // Check if there's an existing ImageGenerationToolResultContent with the same ImageId to replace + if (imageResultIndexById is null) + { + imageResultIndexById = new(StringComparer.Ordinal); + } + + if (imageResultIndexById.TryGetValue(imageResult.ImageId!, out int existingIndex)) + { + // Replace the existing imageResult with the new one + contents[existingIndex] = imageResult; + contents[i] = null!; // Mark the current one for removal, then remove in single o(n) pass + hasRemovals = true; + } + else + { + imageResultIndexById[imageResult.ImageId!] = i; + } + } + } + + // Remove all of the null slots left over from the coalescing process. + if (hasRemovals) + { + RemoveNullContents(contents); + } + } + /// Coalesces sequential content elements. internal static void CoalesceContent(IList contents) { @@ -219,6 +260,8 @@ internal static void CoalesceContent(IList contents) return content; }); + CoalesceImageResultContent(contents); + Coalesce( contents, mergeSingle: false, @@ -394,29 +437,35 @@ static bool TryAsCoalescable(AIContent content, [NotNullWhen(true)] out TContent } // Remove all of the null slots left over from the coalescing process. - if (contents is List contentsList) - { - _ = contentsList.RemoveAll(u => u is null); - } - else - { - int nextSlot = 0; - int contentsCount = contents.Count; - for (int i = 0; i < contentsCount; i++) - { - if (contents[i] is { } content) - { - contents[nextSlot++] = content; - } - } + RemoveNullContents(contents); + } + } - for (int i = contentsCount - 1; i >= nextSlot; i--) + private static void RemoveNullContents(IList contents) + where T : class + { + if (contents is List contentsList) + { + _ = contentsList.RemoveAll(u => u is null); + } + else + { + int nextSlot = 0; + int contentsCount = contents.Count; + for (int i = 0; i < contentsCount; i++) + { + if (contents[i] is { } content) { - contents.RemoveAt(i); + contents[nextSlot++] = content; } + } - Debug.Assert(nextSlot == contents.Count, "Expected final count to equal list length."); + for (int i = contentsCount - 1; i >= nextSlot; i--) + { + contents.RemoveAt(i); } + + Debug.Assert(nextSlot == contents.Count, "Expected final count to equal list length."); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs index c4a9f8ba97c..f1ad70cd22f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs @@ -20,7 +20,7 @@ namespace Microsoft.Extensions.AI; /// /// /// The relationship between and is -/// codified in the and +/// codified in the and /// , which enable bidirectional conversions /// between the two. Note, however, that the provided conversions might be lossy, for example, if multiple /// updates all have different objects whereas there's only one slot for @@ -58,6 +58,29 @@ public ChatResponseUpdate(ChatRole? role, IList? contents) _contents = contents; } + /// + /// Creates a new ChatResponseUpdate instance that is a copy of the current object. + /// + /// The cloned object is a shallow copy; reference-type properties will reference the same + /// objects as the original. Use this method to duplicate the response update for further modification without + /// affecting the original instance. + /// A new ChatResponseUpdate object with the same property values as the current instance. + public ChatResponseUpdate Clone() => + new() + { + AdditionalProperties = AdditionalProperties, + AuthorName = AuthorName, + Contents = Contents, + CreatedAt = CreatedAt, + ConversationId = ConversationId, + FinishReason = FinishReason, + MessageId = MessageId, + ModelId = ModelId, + RawRepresentation = RawRepresentation, + ResponseId = ResponseId, + Role = Role, + }; + /// Gets or sets the name of the author of the response update. public string? AuthorName { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolCallContent.cs new file mode 100644 index 00000000000..f5703a39e69 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolCallContent.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the invocation of an image generation tool call by a hosted service. +/// +[Experimental("MEAI001")] +public sealed class ImageGenerationToolCallContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + public ImageGenerationToolCallContent() + { + } + + /// + /// Gets or sets the unique identifier of the image generation item. + /// + public string? ImageId { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolResultContent.cs new file mode 100644 index 00000000000..2ce6d5045f7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolResultContent.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an image generation tool call invocation by a hosted service. +/// +/// +/// This content type represents when a hosted AI service invokes an image generation tool. +/// It is informational only and represents the call itself, not the result. +/// +[Experimental("MEAI001")] +public sealed class ImageGenerationToolResultContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + public ImageGenerationToolResultContent() + { + } + + /// + /// Gets or sets the unique identifier of the image generation item. + /// + public string? ImageId { get; set; } + + /// + /// Gets or sets the generated content items. + /// + /// + /// Content is typically for images streamed from the tool, or for remotely hosted images, but + /// can also be provider-specific content types that represent the generated images. + /// + public IList? Outputs { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs index fce02e9e88e..586fbcc8bbe 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs @@ -81,6 +81,11 @@ protected ImageGenerationOptions(ImageGenerationOptions? other) /// public ImageGenerationResponseFormat? ResponseFormat { get; set; } + /// + /// Gets or sets the number of intermediate streaming images to generate. + /// + public int? StreamingCount { get; set; } + /// Gets or sets any additional properties associated with the options. public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index 661f5d13af6..22c7461de35 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -4,6 +4,7 @@ Microsoft.Extensions.AI Abstractions representing generative AI components. AI + true @@ -21,7 +22,6 @@ true - true true true true @@ -29,7 +29,7 @@ true - + @@ -37,5 +37,5 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index 64d767a4ea1..b5ef3774b45 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -1247,6 +1247,10 @@ "Member": "Microsoft.Extensions.AI.ChatResponseUpdate.ChatResponseUpdate(Microsoft.Extensions.AI.ChatRole? role, System.Collections.Generic.IList? contents);", "Stage": "Stable" }, + { + "Member": "Microsoft.Extensions.AI.ChatResponseUpdate Microsoft.Extensions.AI.ChatResponseUpdate.Clone();", + "Stage": "Stable" + }, { "Member": "override string Microsoft.Extensions.AI.ChatResponseUpdate.ToString();", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs new file mode 100644 index 00000000000..aca072653ab --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// Represents a hosted tool that can be specified to an AI service to enable it to perform image generation. +/// +/// This tool does not itself implement image generation. It is a marker that can be used to inform a service +/// that the service is allowed to perform image generation if the service is capable of doing so. +/// +[Experimental("MEAI001")] +public class HostedImageGenerationTool : AITool +{ + /// + /// Initializes a new instance of the class with the specified options. + /// + public HostedImageGenerationTool() + { + } + + /// + /// Gets or sets the options used to configure image generation. + /// + public ImageGenerationOptions? Options { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs index 22e3bc6066a..5b3656630e7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.AI; /// Defines the context in which a JSON schema within a type graph is being generated. /// /// -/// This struct is being passed to the user-provided +/// This struct is being passed to the user-provided /// callback by the method and cannot be instantiated directly. /// public readonly struct AIJsonSchemaCreateContext @@ -51,32 +51,20 @@ internal AIJsonSchemaCreateContext(JsonSchemaExporterContext exporterContext) /// Gets the declaring type of the property or parameter being processed. /// public Type? DeclaringType => -#if NET9_0_OR_GREATER _exporterContext.PropertyInfo?.DeclaringType; -#else - _exporterContext.DeclaringType; -#endif /// /// Gets the corresponding to the property or field being processed. /// public ICustomAttributeProvider? PropertyAttributeProvider => -#if NET9_0_OR_GREATER _exporterContext.PropertyInfo?.AttributeProvider; -#else - _exporterContext.PropertyAttributeProvider; -#endif /// /// Gets the of the /// constructor parameter associated with the accompanying . /// public ICustomAttributeProvider? ParameterAttributeProvider => -#if NET9_0_OR_GREATER _exporterContext.PropertyInfo?.AssociatedParameter?.AttributeProvider; -#else - _exporterContext.ParameterInfo; -#endif /// /// Retrieves a custom attribute of a specified type that is applied to the specified schema node context. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index 5602ed3d5d9..667b3c4d080 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -199,11 +199,6 @@ internal static void ValidateSchemaDocument(JsonElement document, [CallerArgumen } } -#if !NET9_0_OR_GREATER - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", - Justification = "Pre STJ-9 schema extraction can fail with a runtime exception if certain reflection metadata have been trimmed. " + - "The exception message will guide users to turn off 'IlcTrimMetadata' which resolves all issues.")] -#endif private static JsonNode CreateJsonSchemaCore( Type? type, ParameterInfo? parameter, 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 3dc80561205..e40a2cc34b1 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 @@ -4,6 +4,7 @@ Microsoft.Extensions.AI Implementation of generative AI abstractions for Azure.AI.Inference. AI + true @@ -29,9 +30,6 @@ - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index c57c3e102cb..491624332da 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## NOT YET RELEASED +## 9.10.2-preview.1.25552.1 - Updated to depend on OpenAI 2.6.0. - Updated the OpenAI Responses `IChatClient` to allow either conversation or response ID for `ChatOptions.ConversationId`. 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 5c801952caa..1cad3704cbb 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 @@ -4,6 +4,7 @@ Microsoft.Extensions.AI Implementation of generative AI abstractions for OpenAI-compatible endpoints. AI + true @@ -31,9 +32,6 @@ - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 846b7de1e9b..6913e999936 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -156,7 +156,7 @@ internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, R if (openAIResponse.OutputItems is not null) { - response.Messages = [.. ToChatMessages(openAIResponse.OutputItems)]; + response.Messages = [.. ToChatMessages(openAIResponse.OutputItems, openAIOptions)]; if (response.Messages.LastOrDefault() is { } lastMessage && openAIResponse.Error is { } error) { @@ -172,7 +172,7 @@ internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, R return response; } - internal static IEnumerable ToChatMessages(IEnumerable items) + internal static IEnumerable ToChatMessages(IEnumerable items, ResponseCreationOptions? options = null) { ChatMessage? message = null; @@ -236,6 +236,10 @@ internal static IEnumerable ToChatMessages(IEnumerable().Select(c => c.VectorStoreId) ?? [], fileSearchTool.MaximumResultCount); + case HostedImageGenerationTool imageGenerationTool: + return ToImageResponseTool(imageGenerationTool); + case HostedCodeInterpreterTool codeTool: return ResponseTool.CreateCodeInterpreterTool( new CodeInterpreterToolContainer(codeTool.Inputs?.OfType().Select(f => f.FileId).ToList() is { Count: > 0 } ids ? @@ -584,6 +604,40 @@ internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, Ch aiFunction.Description); } + internal static ImageGenerationTool ToImageResponseTool(HostedImageGenerationTool imageGenerationTool) + { + ImageGenerationTool result = new(); + ImageGenerationOptions? imageGenerationOptions = imageGenerationTool.Options; + + // Model: Image generation model + result.Model = imageGenerationOptions?.ModelId; + + // Size: Image dimensions (e.g., 1024x1024, 1024x1536) + if (imageGenerationOptions?.ImageSize is not null) + { + result.Size = new ImageGenerationToolSize( + imageGenerationOptions.ImageSize.Value.Width, + imageGenerationOptions.ImageSize.Value.Height); + } + + // OutputFileFormat: File output format + if (imageGenerationOptions?.MediaType is not null) + { + result.OutputFileFormat = imageGenerationOptions.MediaType switch + { + "image/png" => ImageGenerationToolOutputFileFormat.Png, + "image/jpeg" => ImageGenerationToolOutputFileFormat.Jpeg, + "image/webp" => ImageGenerationToolOutputFileFormat.Webp, + _ => null, + }; + } + + // PartialImageCount: Whether to return partial images during generation + result.PartialImageCount ??= imageGenerationOptions?.StreamingCount; + + return result; + } + /// Creates a from a . private static ChatRole ToChatRole(MessageRole? role) => role switch @@ -1220,6 +1274,64 @@ private static void AddCodeInterpreterContents(CodeInterpreterCallResponseItem c }); } + private static void AddImageGenerationContents(ImageGenerationCallResponseItem outputItem, ResponseCreationOptions? options, IList contents) + { + var imageGenTool = options?.Tools.OfType().FirstOrDefault(); + string outputFormat = imageGenTool?.OutputFileFormat?.ToString() ?? "png"; + + contents.Add(new ImageGenerationToolCallContent + { + ImageId = outputItem.Id, + }); + + contents.Add(new ImageGenerationToolResultContent + { + ImageId = outputItem.Id, + RawRepresentation = outputItem, + Outputs = new List + { + new DataContent(outputItem.ImageResultBytes, $"image/{outputFormat}") + } + }); + } + + private static ImageGenerationToolResultContent GetImageGenerationResult(StreamingResponseImageGenerationCallPartialImageUpdate update, ResponseCreationOptions? options) + { + var imageGenTool = options?.Tools.OfType().FirstOrDefault(); + var outputType = imageGenTool?.OutputFileFormat?.ToString() ?? "png"; + + var bytes = update.PartialImageBytes; + + if (bytes is null || bytes.Length == 0) + { + // workaround https://github.com/openai/openai-dotnet/issues/809 + if (update.Patch.TryGetJson("$.partial_image_b64"u8, out var jsonBytes)) + { + Utf8JsonReader reader = new(jsonBytes.Span); + _ = reader.Read(); + bytes = BinaryData.FromBytes(reader.GetBytesFromBase64()); + } + } + + return new ImageGenerationToolResultContent + { + ImageId = update.ItemId, + RawRepresentation = update, + Outputs = new List + { + new DataContent(bytes, $"image/{outputType}") + { + AdditionalProperties = new() + { + [nameof(update.ItemId)] = update.ItemId, + [nameof(update.OutputIndex)] = update.OutputIndex, + [nameof(update.PartialImageIndex)] = update.PartialImageIndex + } + } + } + }; + } + private static OpenAIResponsesContinuationToken? CreateContinuationToken(OpenAIResponse openAIResponse) { return CreateContinuationToken( diff --git a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md index d0de268ecaf..f3c53f0d8a1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## NOT YET RELEASED +## 9.10.2 - Updated the Open Telemetry instrumentation to conform to the latest 1.38 draft specification of the Semantic Conventions for Generative AI systems. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs index a7a6c903834..5a881397917 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs @@ -22,9 +22,7 @@ public class ChatResponse : ChatResponse { private static readonly JsonReaderOptions _allowMultipleValuesJsonReaderOptions = new() { -#if NET9_0_OR_GREATER AllowMultipleValues = true -#endif }; private readonly JsonSerializerOptions _serializerOptions; diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClient.cs new file mode 100644 index 00000000000..436adeb2295 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClient.cs @@ -0,0 +1,518 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating chat client that enables image generation capabilities by converting instances to function tools. +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// employed is also thread-safe for concurrent use. +/// +/// +/// This client automatically detects instances in the collection +/// and replaces them with equivalent function tools that the chat client can invoke to perform image generation and editing operations. +/// +/// +[Experimental("MEAI001")] +public sealed class ImageGeneratingChatClient : DelegatingChatClient +{ + /// + /// Specifies how image and other data content is handled when passing data to an inner client. + /// + /// + /// Use this enumeration to control whether images in the data content are passed as-is, replaced + /// with unique identifiers, or only generated images are replaced. This setting affects how downstream clients + /// receive and process image data. + /// Reducing what's passed downstream can help manage the context window. + /// + public enum DataContentHandling + { + /// Pass all DataContent to inner client. + None, + + /// Replace all images with unique identifiers when passing to inner client. + AllImages, + + /// Replace only images that were produced by past image generation requests with unique identifiers when passing to inner client. + GeneratedImages + } + + private const string ImageKey = "meai_image"; + + private readonly IImageGenerator _imageGenerator; + private readonly DataContentHandling _dataContentHandling; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used for image generation operations. + /// Specifies how to handle instances when passing messages to the inner client. + /// The default is . + /// or is . + public ImageGeneratingChatClient(IChatClient innerClient, IImageGenerator imageGenerator, DataContentHandling dataContentHandling = DataContentHandling.AllImages) + : base(innerClient) + { + _imageGenerator = Throw.IfNull(imageGenerator); + _dataContentHandling = dataContentHandling; + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + var requestState = new RequestState(_imageGenerator, _dataContentHandling); + + // Process the chat options to replace HostedImageGenerationTool with functions + var processedOptions = requestState.ProcessChatOptions(options); + var processedMessages = requestState.ProcessChatMessages(messages); + + // Get response from base implementation + var response = await base.GetResponseAsync(processedMessages, processedOptions, cancellationToken); + + // Replace FunctionResultContent instances with generated image content + foreach (var message in response.Messages) + { + message.Contents = requestState.ReplaceImageGenerationFunctionResults(message.Contents); + } + + return response; + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + var requestState = new RequestState(_imageGenerator, _dataContentHandling); + + // Process the chat options to replace HostedImageGenerationTool with functions + var processedOptions = requestState.ProcessChatOptions(options); + var processedMessages = requestState.ProcessChatMessages(messages); + + await foreach (var update in base.GetStreamingResponseAsync(processedMessages, processedOptions, cancellationToken)) + { + // Replace any FunctionResultContent instances with generated image content + var newContents = requestState.ReplaceImageGenerationFunctionResults(update.Contents); + + if (!ReferenceEquals(newContents, update.Contents)) + { + // Create a new update instance with modified contents + var modifiedUpdate = update.Clone(); + modifiedUpdate.Contents = newContents; + yield return modifiedUpdate; + } + else + { + yield return update; + } + } + } + + /// Provides a mechanism for releasing unmanaged resources. + /// to dispose managed resources; otherwise, . + protected override void Dispose(bool disposing) + { + if (disposing) + { + _imageGenerator.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Contains all the per-request state and methods for handling image generation requests. + /// This class is created fresh for each request to ensure thread safety. + /// This class is not exposed publicly and does not own any of it's resources. + /// + private sealed class RequestState + { + private readonly IImageGenerator _imageGenerator; + private readonly DataContentHandling _dataContentHandling; + private readonly HashSet _toolNames = new(StringComparer.Ordinal); + private readonly Dictionary> _imageContentByCallId = []; + private readonly Dictionary _imageContentById = new(StringComparer.OrdinalIgnoreCase); + private ImageGenerationOptions? _imageGenerationOptions; + + public RequestState(IImageGenerator imageGenerator, DataContentHandling dataContentHandling) + { + _imageGenerator = imageGenerator; + _dataContentHandling = dataContentHandling; + } + + /// + /// Processes the chat messages to replace images in data content with unique identifiers as needed. + /// All images will be stored for later retrieval during image editing operations. + /// See for details on image replacement behavior. + /// + /// Messages to process. + /// Processed messages, or the original messages if no changes were made. + public IEnumerable ProcessChatMessages(IEnumerable messages) + { + List? newMessages = null; + int messageIndex = 0; + foreach (var message in messages) + { + List? newContents = null; + for (int contentIndex = 0; contentIndex < message.Contents.Count; contentIndex++) + { + var content = message.Contents[contentIndex]; + + void ReplaceImage(string imageId, DataContent dataContent) + { + // Replace image with a placeholder text content, to give an indication to the model of its placement in the context + newContents ??= CopyList(message.Contents, contentIndex); + newContents.Add(new TextContent($"[{ImageKey}:{imageId}] available for edit.") + { + Annotations = dataContent.Annotations, + AdditionalProperties = dataContent.AdditionalProperties + }); + } + + if (content is DataContent dataContent && dataContent.HasTopLevelMediaType("image")) + { + // Store the image to make available for edit + var imageId = StoreImage(dataContent); + + if (_dataContentHandling == DataContentHandling.AllImages) + { + ReplaceImage(imageId, dataContent); + continue; // Skip adding the original content + } + } + else if (content is ImageGenerationToolResultContent toolResultContent) + { + foreach (var output in toolResultContent.Outputs ?? []) + { + if (output is DataContent generatedDataContent && generatedDataContent.HasTopLevelMediaType("image")) + { + // Store the image to make available for edit + var imageId = StoreImage(generatedDataContent, isGenerated: true); + + if (_dataContentHandling == DataContentHandling.AllImages || + _dataContentHandling == DataContentHandling.GeneratedImages) + { + ReplaceImage(imageId, generatedDataContent); + } + } + } + + if (_dataContentHandling == DataContentHandling.AllImages || + _dataContentHandling == DataContentHandling.GeneratedImages) + { + // skip adding the generated content + continue; + } + } + + // Add the original content if no replacement was made + newContents?.Add(content); + } + + if (newContents != null) + { + newMessages ??= [.. messages.Take(messageIndex)]; + var newMessage = message.Clone(); + newMessage.Contents = newContents; + newMessages.Add(newMessage); + } + else + { + newMessages?.Add(message); + + } + + messageIndex++; + } + + return newMessages ?? messages; + } + + public ChatOptions? ProcessChatOptions(ChatOptions? options) + { + if (options?.Tools is null || options.Tools.Count == 0) + { + return options; + } + + List? newTools = null; + var tools = options.Tools; + for (int i = 0; i < tools.Count; i++) + { + var tool = tools[i]; + + // remove all instances of HostedImageGenerationTool and store the options from the last one + if (tool is HostedImageGenerationTool imageGenerationTool) + { + _imageGenerationOptions = imageGenerationTool.Options; + + // for the first image generation tool, clone the options and insert our function tools + // remove any subsequent image generation tools + newTools ??= InitializeTools(tools, i); + } + else + { + newTools?.Add(tool); + } + } + + if (newTools is not null) + { + var newOptions = options.Clone(); + newOptions.Tools = newTools; + return newOptions; + } + + return options; + + List InitializeTools(IList existingTools, int toOffsetExclusive) + { +#if NET + ReadOnlySpan tools = +#else + AITool[] tools = +#endif + [ + AIFunctionFactory.Create(GenerateImageAsync), + AIFunctionFactory.Create(EditImageAsync), + AIFunctionFactory.Create(GetImagesForEdit) + ]; + + foreach (var tool in tools) + { + _toolNames.Add(tool.Name); + } + + var result = CopyList(existingTools, toOffsetExclusive, tools.Length); + result.AddRange(tools); + return result; + } + } + + /// + /// Replaces FunctionResultContent instances for image generation functions with actual generated image content. + /// We will have two messages + /// 1. Role: Assistant, FunctionCall + /// 2. Role: Tool, FunctionResult + /// We need to replace content from both but we shouldn't remove the messages. + /// If we do not then ChatClient's may not accept our altered history. + /// + /// When interating with a HostedImageGenerationTool we will have typically only see a single Message with + /// Role: Assistant that contains the DataContent (or a provider specific content, that's exposed as DataContent). + /// + /// The list of AI content to process. + public IList ReplaceImageGenerationFunctionResults(IList contents) + { + List? newContents = null; + + // Replace FunctionResultContent instances with generated image content + for (int i = contents.Count - 1; i >= 0; i--) + { + var content = contents[i]; + + // We must lookup by name because in the streaming case we have not yet been called to record the CallId. + if (content is FunctionCallContent functionCall && + _toolNames.Contains(functionCall.Name)) + { + // create a new list and omit the FunctionCallContent + newContents ??= CopyList(contents, i); + + if (functionCall.Name != nameof(GetImagesForEdit)) + { + newContents.Add(new ImageGenerationToolCallContent + { + ImageId = functionCall.CallId, + }); + } + } + else if (content is FunctionResultContent functionResult && + _imageContentByCallId.TryGetValue(functionResult.CallId, out var imageContents)) + { + newContents ??= CopyList(contents, i); + + if (imageContents.Any()) + { + // Insert ImageGenerationToolResultContent in its place, do not preserve the FunctionResultContent + newContents.Add(new ImageGenerationToolResultContent + { + ImageId = functionResult.CallId, + Outputs = imageContents + }); + } + + // Remove the mapping as it's no longer needed + _ = _imageContentByCallId.Remove(functionResult.CallId); + } + else + { + // keep the existing content if we have a new list + newContents?.Add(content); + } + } + + return newContents ?? contents; + } + + [Description("Generates images based on a text description.")] + public async Task GenerateImageAsync( + [Description("A detailed description of the image to generate")] string prompt, + CancellationToken cancellationToken = default) + { + // Get the call ID from the current function invocation context + var callId = FunctionInvokingChatClient.CurrentContext?.CallContent.CallId; + if (callId == null) + { + return "No call ID available for image generation."; + } + + var request = new ImageGenerationRequest(prompt); + var options = _imageGenerationOptions ?? new ImageGenerationOptions(); + options.Count ??= 1; + + var response = await _imageGenerator.GenerateAsync(request, options, cancellationToken); + + if (response.Contents.Count == 0) + { + return "No image was generated."; + } + + List imageIds = []; + List imageContents = _imageContentByCallId[callId] = []; + foreach (var content in response.Contents) + { + if (content is DataContent imageContent && imageContent.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + imageContents.Add(imageContent); + imageIds.Add(StoreImage(imageContent, true)); + } + } + + return "Generated image successfully."; + } + + [Description("Lists the identifiers of all images available for edit.")] + public IEnumerable GetImagesForEdit() + { + // Get the call ID from the current function invocation context + var callId = FunctionInvokingChatClient.CurrentContext?.CallContent.CallId; + if (callId == null) + { + return ["No call ID available for image editing."]; + } + + _imageContentByCallId[callId] = []; + + return _imageContentById.Keys.AsEnumerable(); + } + + [Description("Edits an existing image based on a text description.")] + public async Task EditImageAsync( + [Description("A detailed description of the image to generate")] string prompt, + [Description($"The image to edit from one of the available image identifiers returned by {nameof(GetImagesForEdit)}")] string imageId, + CancellationToken cancellationToken = default) + { + // Get the call ID from the current function invocation context + var callId = FunctionInvokingChatClient.CurrentContext?.CallContent.CallId; + if (callId == null) + { + return "No call ID available for image editing."; + } + + if (string.IsNullOrEmpty(imageId)) + { + return "No imageId provided"; + } + + try + { + var originalImage = RetrieveImageContent(imageId); + if (originalImage == null) + { + return $"No image found with: {imageId}"; + } + + var request = new ImageGenerationRequest(prompt, [originalImage]); + var response = await _imageGenerator.GenerateAsync(request, _imageGenerationOptions, cancellationToken); + + if (response.Contents.Count == 0) + { + return "No edited image was generated."; + } + + List imageIds = []; + List imageContents = _imageContentByCallId[callId] = []; + foreach (var content in response.Contents) + { + if (content is DataContent imageContent && imageContent.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + imageContents.Add(imageContent); + imageIds.Add(StoreImage(imageContent, true)); + } + } + + return "Edited image successfully."; + } + catch (FormatException) + { + return "Invalid image data format. Please provide a valid base64-encoded image."; + } + } + + private static List CopyList(IList original, int toOffsetExclusive, int additionalCapacity = 0) + { + var newList = new List(original.Count + additionalCapacity); + + // Copy all items up to and excluding the current index + for (int j = 0; j < toOffsetExclusive; j++) + { + newList.Add(original[j]); + } + + return newList; + } + + private DataContent? RetrieveImageContent(string imageId) + { + if (_imageContentById.TryGetValue(imageId, out var imageContent)) + { + return imageContent as DataContent; + } + + return null; + } + + private string StoreImage(DataContent imageContent, bool isGenerated = false) + { + // Generate a unique ID for the image if it doesn't have one + string? imageId = null; + if (imageContent.AdditionalProperties?.TryGetValue(ImageKey, out imageId) is false || imageId is null) + { + imageId = imageContent.Name ?? Guid.NewGuid().ToString(); + } + + if (isGenerated) + { + imageContent.AdditionalProperties ??= []; + imageContent.AdditionalProperties[ImageKey] = imageId; + } + + // Store the image content for later retrieval + _imageContentById[imageId] = imageContent; + + return imageId; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..241c851fd4e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClientBuilderExtensions.cs @@ -0,0 +1,46 @@ +// 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 Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class ImageGeneratingChatClientBuilderExtensions +{ + /// Adds image generation capabilities to the chat client pipeline. + /// The . + /// + /// An optional used for image generation operations. + /// If not supplied, a required instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The . + /// is . + /// + /// + /// This method enables the chat client to handle instances by converting them + /// into function tools that can be invoked by the underlying chat model to perform image generation and editing operations. + /// + /// + public static ChatClientBuilder UseImageGeneration( + this ChatClientBuilder builder, + IImageGenerator? imageGenerator = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + { + imageGenerator ??= services.GetRequiredService(); + + var chatClient = new ImageGeneratingChatClient(innerClient, imageGenerator); + configure?.Invoke(chatClient); + return chatClient; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index 16047a72b51..1f630a5a62c 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -69,19 +69,15 @@ public OpenTelemetryChatClient(IChatClient innerClient, ILogger? logger = null, _tokenUsageHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, OpenTelemetryConsts.TokensUnit, - OpenTelemetryConsts.GenAI.Client.TokenUsage.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } ); _operationDurationHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, OpenTelemetryConsts.SecondsUnit, - OpenTelemetryConsts.GenAI.Client.OperationDuration.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } ); _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs index 9ed2864c831..aadf5f3fed6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs @@ -62,19 +62,15 @@ public OpenTelemetryImageGenerator(IImageGenerator innerGenerator, ILogger? logg _tokenUsageHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, OpenTelemetryConsts.TokensUnit, - OpenTelemetryConsts.GenAI.Client.TokenUsage.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } ); _operationDurationHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, OpenTelemetryConsts.SecondsUnit, - OpenTelemetryConsts.GenAI.Client.OperationDuration.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } ); } diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs index 9ad1d752270..f13f7273d89 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -67,19 +67,15 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator i _tokenUsageHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, OpenTelemetryConsts.TokensUnit, - OpenTelemetryConsts.GenAI.Client.TokenUsage.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } ); _operationDurationHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, OpenTelemetryConsts.SecondsUnit, - OpenTelemetryConsts.GenAI.Client.OperationDuration.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } ); } diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 54cbcc99754..960ae56de65 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -4,6 +4,7 @@ Microsoft.Extensions.AI Utilities for working with generative AI components. AI + true @@ -24,7 +25,7 @@ at all then to spot ones that use false rather than true. Alternatively, we could try to avoid using ConfigureAwait(false) only on paths that could lead up to the invocation of an AIFunction, but that is challenging to maintain correctly. --> $(NoWarn);CA2007 - + true true @@ -47,7 +48,7 @@ - + @@ -56,5 +57,5 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs index f3aa90a4b21..3b0688ba585 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs @@ -64,19 +64,15 @@ public OpenTelemetrySpeechToTextClient(ISpeechToTextClient innerClient, ILogger? _tokenUsageHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, OpenTelemetryConsts.TokensUnit, - OpenTelemetryConsts.GenAI.Client.TokenUsage.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } ); _operationDurationHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, OpenTelemetryConsts.SecondsUnit, - OpenTelemetryConsts.GenAI.Client.OperationDuration.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } ); } diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj index ba7ec363bc8..f3f16874b4c 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj @@ -3,13 +3,16 @@ $(TargetFrameworks);netstandard2.0 Microsoft.Extensions.DataIngestion - - - false + Abstractions representing Data Ingestion components for RAG. + RAG + RAG;ingestion;documents + true + preview + false + 75 + 75 $(NoWarn);S1694 - preview - false diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/README.md new file mode 100644 index 00000000000..0285f27fb3d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/README.md @@ -0,0 +1,39 @@ +# Microsoft.Extensions.DataIngestion.Abstractions + +.NET developers need to efficiently process, chunk, and retrieve information from diverse document formats while preserving semantic meaning and structural context. The `Microsoft.Extensions.DataIngestion` libraries provide a unified approach for representing document ingestion components. + +## The packages + +The [Microsoft.Extensions.DataIngestion.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion.Abstractions) package provides the core exchange types, including [`IngestionDocument`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestiondocument), [`IngestionChunker`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunker-1), [`IngestionChunkProcessor`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkprocessor-1), and [`IngestionChunkWriter`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkwriter-1). Any .NET library that provides document processing capabilities can implement these abstractions to enable seamless integration with consuming code. + +The [Microsoft.Extensions.DataIngestion](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion) package has an implicit dependency on the `Microsoft.Extensions.DataIngestion.Abstractions` package. This package enables you to easily integrate components such as enrichment processors, vector storage writers, and telemetry into your applications using familiar dependency injection and pipeline patterns. For example, it provides processors for sentiment analysis, keyword extraction, and summarization that can be chained together in ingestion pipelines. + +## Which package to reference + +Libraries that provide implementations of the abstractions typically reference only `Microsoft.Extensions.DataIngestion.Abstractions`. + +To also have access to higher-level utilities for working with document ingestion components, reference the `Microsoft.Extensions.DataIngestion` package instead (which itself references `Microsoft.Extensions.DataIngestion.Abstractions`). Most consuming applications and services should reference the `Microsoft.Extensions.DataIngestion` package along with one or more libraries that provide concrete implementations of the abstractions, such as `Microsoft.Extensions.DataIngestion.MarkItDown` or `Microsoft.Extensions.DataIngestion.Markdig`. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion.Abstractions --prerelease +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Documentation + +Refer to the [Microsoft.Extensions.DataIngestion libraries documentation](https://learn.microsoft.com/dotnet/dataingestion/microsoft-extensions-dataingestion) for more information and API usage examples. + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs new file mode 100644 index 00000000000..b75fc2e7f50 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Reads documents by converting them to Markdown using the MarkItDown MCP server. +/// +public class MarkItDownMcpReader : IngestionDocumentReader +{ + private readonly Uri _mcpServerUri; + private readonly McpClientOptions? _options; + + /// + /// Initializes a new instance of the class. + /// + /// The URI of the MarkItDown MCP server (e.g., http://localhost:3001/mcp). + /// Optional MCP client options for configuring the connection. + public MarkItDownMcpReader(Uri mcpServerUri, McpClientOptions? options = null) + { + _mcpServerUri = Throw.IfNull(mcpServerUri); + _options = options; + } + + /// + public override async Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + if (!source.Exists) + { + throw new FileNotFoundException("The specified file does not exist.", source.FullName); + } + + // Read file content as base64 data URI +#if NET + byte[] fileBytes = await File.ReadAllBytesAsync(source.FullName, cancellationToken).ConfigureAwait(false); +#else + byte[] fileBytes; + using (FileStream fs = new(source.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 1, FileOptions.Asynchronous)) + { + using MemoryStream ms = new(); + await fs.CopyToAsync(ms).ConfigureAwait(false); + fileBytes = ms.ToArray(); + } +#endif + string dataUri = CreateDataUri(fileBytes, mediaType); + + string markdown = await ConvertToMarkdownAsync(dataUri, cancellationToken).ConfigureAwait(false); + + return MarkdownParser.Parse(markdown, identifier); + } + + /// + public override async Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + // Read stream content as base64 data URI + using MemoryStream ms = new(); +#if NET + await source.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); +#else + await source.CopyToAsync(ms).ConfigureAwait(false); +#endif + byte[] fileBytes = ms.ToArray(); + string dataUri = CreateDataUri(fileBytes, mediaType); + + string markdown = await ConvertToMarkdownAsync(dataUri, cancellationToken).ConfigureAwait(false); + + return MarkdownParser.Parse(markdown, identifier); + } + +#pragma warning disable S3995 // URI return values should not be strings + private static string CreateDataUri(byte[] fileBytes, string? mediaType) +#pragma warning restore S3995 // URI return values should not be strings + { + string base64Content = Convert.ToBase64String(fileBytes); + string mimeType = string.IsNullOrEmpty(mediaType) ? "application/octet-stream" : mediaType!; + return $"data:{mimeType};base64,{base64Content}"; + } + + private async Task ConvertToMarkdownAsync(string dataUri, CancellationToken cancellationToken) + { + // Create HTTP client transport for MCP + HttpClientTransport transport = new(new HttpClientTransportOptions + { + Endpoint = _mcpServerUri + }); + + await using (transport.ConfigureAwait(false)) + { + // Create MCP client + McpClient client = await McpClient.CreateAsync(transport, _options, loggerFactory: null, cancellationToken).ConfigureAwait(false); + + await using (client.ConfigureAwait(false)) + { + // Build parameters for convert_to_markdown tool + Dictionary parameters = new() + { + ["uri"] = dataUri + }; + + // Call the convert_to_markdown tool + var result = await client.CallToolAsync("convert_to_markdown", parameters, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Extract markdown content from result + // The result is expected to be in the format: { "content": [{ "type": "text", "text": "markdown content" }] } + if (result.Content != null && result.Content.Count > 0) + { + foreach (var content in result.Content) + { + if (content.Type == "text" && content is TextContentBlock textBlock) + { + return textBlock.Text; + } + } + } + } + } + + throw new InvalidOperationException("Failed to convert document to markdown: unexpected response format from MCP server."); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj index 74fab572963..013097ea6c7 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj @@ -3,11 +3,14 @@ $(TargetFrameworks);netstandard2.0 Microsoft.Extensions.DataIngestion - - - false + Implementation of IngestionDocumentReader abstraction for MarkItDown. + RAG + RAG;ingestion;documents;markitdown + true preview false + 75 + 75 @@ -20,6 +23,7 @@ + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/README.md new file mode 100644 index 00000000000..095011b77f1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/README.md @@ -0,0 +1,91 @@ +# Microsoft.Extensions.DataIngestion.MarkItDown + +Provides an implementation of the `IngestionDocumentReader` class for the [MarkItDown](https://github.com/microsoft/markitdown/) utility. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion.MarkItDown --prerelease +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +### Creating a MarkItDownReader for Data Ingestion (Local Process) + +Use `MarkItDownReader` to convert documents using the MarkItDown executable installed locally: + +```csharp +using Microsoft.Extensions.DataIngestion; + +IngestionDocumentReader reader = + new MarkItDownReader(new FileInfo(@"pathToMarkItDown.exe"), extractImages: true); + +using IngestionPipeline pipeline = new(reader, CreateChunker(), CreateWriter()); +``` + +### Creating a MarkItDownMcpReader for Data Ingestion (MCP Server) + +Use `MarkItDownMcpReader` to convert documents using a MarkItDown MCP server: + +```csharp +using Microsoft.Extensions.DataIngestion; + +// Connect to a MarkItDown MCP server (e.g., running in Docker) +IngestionDocumentReader reader = + new MarkItDownMcpReader(new Uri("http://localhost:3001/mcp")); + +using IngestionPipeline pipeline = new(reader, CreateChunker(), CreateWriter()); +``` + +The MarkItDown MCP server can be run using Docker: + +```bash +docker run -p 3001:3001 mcp/markitdown --http --host 0.0.0.0 --port 3001 +``` + +Or installed via pip: + +```bash +pip install markitdown-mcp-server +markitdown-mcp --http --host 0.0.0.0 --port 3001 +``` + +### Integrating with Aspire + +Aspire can be used for seamless integration with [MarkItDown MCP](https://github.com/microsoft/markitdown/tree/main/packages/markitdown-mcp). Sample AppHost logic: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var markitdown = builder.AddContainer("markitdown", "mcp/markitdown") + .WithArgs("--http", "--host", "0.0.0.0", "--port", "3001") + .WithHttpEndpoint(targetPort: 3001, name: "http"); + +var webApp = builder.AddProject("name"); + +webApp.WithEnvironment("MARKITDOWN_MCP_URL", markitdown.GetEndpoint("http")); + +builder.Build().Run(); +``` + +Sample Ingestion Service: + +```csharp +string url = $"{Environment.GetEnvironmentVariable("MARKITDOWN_MCP_URL")}/mcp"; + +IngestionDocumentReader reader = new MarkItDownMcpReader(new Uri(url)); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/Microsoft.Extensions.DataIngestion.Markdig.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/Microsoft.Extensions.DataIngestion.Markdig.csproj index eb47fca74b7..5dbe9c27a34 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/Microsoft.Extensions.DataIngestion.Markdig.csproj +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/Microsoft.Extensions.DataIngestion.Markdig.csproj @@ -3,11 +3,14 @@ $(TargetFrameworks);netstandard2.0 Microsoft.Extensions.DataIngestion - - - false + Implementation of IngestionDocumentReader abstraction for Markdown. + RAG + RAG;ingestion;documents;markdown + true preview false + 75 + 75 diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/README.md new file mode 100644 index 00000000000..c6a2328699c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/README.md @@ -0,0 +1,35 @@ +# Microsoft.Extensions.DataIngestion.Markdig + +Provides an implementation of the `IngestionDocumentReader` class for the Markdown files using [MarkDig](https://github.com/xoofx/markdig) library. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion.Markdig --prerelease +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +### Creating a MarkdownReader for Data Ingestion + +```csharp +using Microsoft.Extensions.DataIngestion; + +IngestionDocumentReader reader = new MarkdownReader(); + +using IngestionPipeline pipeline = new(reader, CreateChunker(), CreateWriter()); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipeline.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipeline.cs index c027b934297..1eeb94058ee 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipeline.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipeline.cs @@ -154,7 +154,7 @@ private async IAsyncEnumerable ProcessAsync(IEnumerable ProcessAsync(IEnumerable IngestAsync(IngestionDocument document, Activity? parentActivity, CancellationToken cancellationToken) { foreach (IngestionDocumentProcessor processor in DocumentProcessors) { @@ -188,5 +189,7 @@ private async Task IngestAsync(IngestionDocument document, Activity? parentActiv _logger?.WritingChunks(GetShortName(_writer)); await _writer.WriteAsync(chunks, cancellationToken).ConfigureAwait(false); _logger?.WroteChunks(document.Identifier); + + return document; } } diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionResult.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionResult.cs index 3e325116be3..1a4e57ea3b8 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionResult.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionResult.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.IO; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.DataIngestion; @@ -13,9 +12,9 @@ namespace Microsoft.Extensions.DataIngestion; public sealed class IngestionResult { /// - /// Gets the source file that was ingested. + /// Gets the ID of the document that was ingested. /// - public FileInfo Source { get; } + public string DocumentId { get; } /// /// Gets the ingestion document created from the source file, if reading the document has succeeded. @@ -32,9 +31,9 @@ public sealed class IngestionResult /// public bool Succeeded => Exception is null; - internal IngestionResult(FileInfo source, IngestionDocument? document, Exception? exception) + internal IngestionResult(string documentId, IngestionDocument? document, Exception? exception) { - Source = Throw.IfNull(source); + DocumentId = Throw.IfNullOrEmpty(documentId); Document = document; Exception = exception; } diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Log.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Log.cs index 0d1ce4daa13..58732e8ead7 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion/Log.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Log.cs @@ -31,5 +31,11 @@ internal static partial class Log [LoggerMessage(6, LogLevel.Error, "An error occurred while ingesting document '{identifier}'.")] internal static partial void IngestingFailed(this ILogger logger, Exception exception, string identifier); + + [LoggerMessage(7, LogLevel.Error, "The AI chat service returned {resultCount} instead of {expectedCount} results.")] + internal static partial void UnexpectedResultsCount(this ILogger logger, int resultCount, int expectedCount); + + [LoggerMessage(8, LogLevel.Error, "Unexpected enricher failure.")] + internal static partial void UnexpectedEnricherFailure(this ILogger logger, Exception exception); } } diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Microsoft.Extensions.DataIngestion.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion/Microsoft.Extensions.DataIngestion.csproj index 856c8811a02..b7515183a86 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion/Microsoft.Extensions.DataIngestion.csproj +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Microsoft.Extensions.DataIngestion.csproj @@ -3,35 +3,29 @@ $(TargetFrameworks);netstandard2.0 Microsoft.Extensions.DataIngestion - + Data Ingestion utilities for RAG. + RAG + RAG;ingestion;documents true false - - - false + true preview false + 75 + 75 + - - - - - - - - - - + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ClassificationEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ClassificationEnricher.cs index e1cb1ca7438..ad7b7d645d6 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ClassificationEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ClassificationEnricher.cs @@ -2,13 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Frozen; using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Text; using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.DataIngestion; @@ -21,30 +19,28 @@ namespace Microsoft.Extensions.DataIngestion; /// an optional fallback class for cases where no suitable classification can be determined. public sealed class ClassificationEnricher : IngestionChunkProcessor { - private readonly IChatClient _chatClient; - private readonly ChatOptions? _chatOptions; - private readonly FrozenSet _predefinedClasses; + private readonly EnricherOptions _options; private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; /// /// Initializes a new instance of the class. /// - /// The chat client used for classification. + /// The options for the classification enricher. /// The set of predefined classification classes. - /// Options for the chat client. /// The fallback class to use when no suitable classification is found. When not provided, it defaults to "Unknown". - public ClassificationEnricher(IChatClient chatClient, ReadOnlySpan predefinedClasses, - ChatOptions? chatOptions = null, string? fallbackClass = null) + public ClassificationEnricher(EnricherOptions options, ReadOnlySpan predefinedClasses, + string? fallbackClass = null) { - _chatClient = Throw.IfNull(chatClient); - _chatOptions = chatOptions; + _options = Throw.IfNull(options).Clone(); if (string.IsNullOrWhiteSpace(fallbackClass)) { fallbackClass = "Unknown"; } - _predefinedClasses = CreatePredefinedSet(predefinedClasses, fallbackClass!); + Validate(predefinedClasses, fallbackClass!); _systemPrompt = CreateSystemPrompt(predefinedClasses, fallbackClass!); + _logger = _options.LoggerFactory?.CreateLogger(); } /// @@ -53,28 +49,10 @@ public ClassificationEnricher(IChatClient chatClient, ReadOnlySpan prede public static string MetadataKey => "classification"; /// - public override async IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(chunks); - - await foreach (IngestionChunk chunk in chunks.WithCancellation(cancellationToken)) - { - var response = await _chatClient.GetResponseAsync( - [ - _systemPrompt, - new(ChatRole.User, chunk.Content) - ], _chatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); - - chunk.Metadata[MetadataKey] = _predefinedClasses.Contains(response.Text) - ? response.Text - : throw new InvalidOperationException($"Classification returned an unexpected class: '{response.Text}'."); - - yield return chunk; - } - } + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); - private static FrozenSet CreatePredefinedSet(ReadOnlySpan predefinedClasses, string fallbackClass) + private static void Validate(ReadOnlySpan predefinedClasses, string fallbackClass) { if (predefinedClasses.Length == 0) { @@ -84,15 +62,6 @@ private static FrozenSet CreatePredefinedSet(ReadOnlySpan predef HashSet predefinedClassesSet = new(StringComparer.Ordinal) { fallbackClass }; foreach (string predefinedClass in predefinedClasses) { -#if NET - if (predefinedClass.Contains(',', StringComparison.Ordinal)) -#else - if (predefinedClass.IndexOf(',') >= 0) -#endif - { - Throw.ArgumentException(nameof(predefinedClasses), $"Predefined class '{predefinedClass}' must not contain ',' character."); - } - if (!predefinedClassesSet.Add(predefinedClass)) { if (predefinedClass.Equals(fallbackClass, StringComparison.Ordinal)) @@ -103,13 +72,11 @@ private static FrozenSet CreatePredefinedSet(ReadOnlySpan predef Throw.ArgumentException(nameof(predefinedClasses), $"Duplicate class found: '{predefinedClass}'."); } } - - return predefinedClassesSet.ToFrozenSet(); } private static ChatMessage CreateSystemPrompt(ReadOnlySpan predefinedClasses, string fallbackClass) { - StringBuilder sb = new("You are a classification expert. Analyze the given text and assign a single, most relevant class. Use only the following predefined classes: "); + StringBuilder sb = new("You are a classification expert. For each of the following texts, assign a single, most relevant class. Use only the following predefined classes: "); #if NET9_0_OR_GREATER sb.AppendJoin(", ", predefinedClasses!); diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/EnricherOptions.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/EnricherOptions.cs new file mode 100644 index 00000000000..182e07d9c1f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/EnricherOptions.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Represents options for enrichers that use an AI chat client. +/// +public class EnricherOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The AI chat client to be used. + public EnricherOptions(IChatClient chatClient) + { + ChatClient = Throw.IfNull(chatClient); + } + + /// + /// Gets the AI chat client to be used. + /// + public IChatClient ChatClient { get; } + + /// + /// Gets or sets the options for the . + /// + public ChatOptions? ChatOptions { get; set; } + + /// + /// Gets or sets the logger factory to be used for logging. + /// + /// + /// Enricher failures should not fail the whole ingestion pipeline, as they are best-effort enhancements. + /// This logger factory can be used to create loggers to log such failures. + /// + public ILoggerFactory? LoggerFactory { get; set; } + + /// + /// Gets or sets the batch size for processing chunks. Default is 20. + /// + public int BatchSize { get; set => field = Throw.IfLessThanOrEqual(value, 0); } = 20; + + internal EnricherOptions Clone() => new(ChatClient) + { + ChatOptions = ChatOptions?.Clone(), + LoggerFactory = LoggerFactory, + BatchSize = BatchSize + }; +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ImageAlternativeTextEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ImageAlternativeTextEnricher.cs index 5f68552cc3f..b133e0fa31a 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ImageAlternativeTextEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ImageAlternativeTextEnricher.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.DataIngestion; @@ -15,20 +17,19 @@ namespace Microsoft.Extensions.DataIngestion; /// public sealed class ImageAlternativeTextEnricher : IngestionDocumentProcessor { - private readonly IChatClient _chatClient; - private readonly ChatOptions? _chatOptions; + private readonly EnricherOptions _options; private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; /// /// Initializes a new instance of the class. /// - /// The chat client used to get responses for generating alternative text. - /// Options for the chat client. - public ImageAlternativeTextEnricher(IChatClient chatClient, ChatOptions? chatOptions = null) + /// The options for generating alternative text. + public ImageAlternativeTextEnricher(EnricherOptions options) { - _chatClient = Throw.IfNull(chatClient); - _chatOptions = chatOptions; - _systemPrompt = new(ChatRole.System, "Write a detailed alternative text for this image with less than 50 words."); + _options = Throw.IfNull(options).Clone(); + _systemPrompt = new(ChatRole.System, "For each of the following images, write a detailed alternative text with fewer than 50 words."); + _logger = _options.LoggerFactory?.CreateLogger(); } /// @@ -36,39 +37,86 @@ public override async Task ProcessAsync(IngestionDocument doc { _ = Throw.IfNull(document); + List? batch = null; + foreach (var element in document.EnumerateContent()) { if (element is IngestionDocumentImage image) { - await ProcessAsync(image, cancellationToken).ConfigureAwait(false); + if (ShouldProcess(image)) + { + batch ??= new(_options.BatchSize); + batch.Add(image); + + if (batch.Count == _options.BatchSize) + { + await ProcessAsync(batch, cancellationToken).ConfigureAwait(false); + batch.Clear(); + } + } } else if (element is IngestionDocumentTable table) { foreach (var cell in table.Cells) { - if (cell is IngestionDocumentImage cellImage) + if (cell is IngestionDocumentImage cellImage && ShouldProcess(cellImage)) { - await ProcessAsync(cellImage, cancellationToken).ConfigureAwait(false); + batch ??= new(_options.BatchSize); + batch.Add(cellImage); + + if (batch.Count == _options.BatchSize) + { + await ProcessAsync(batch, cancellationToken).ConfigureAwait(false); + batch.Clear(); + } } } } } + if (batch?.Count > 0) + { + await ProcessAsync(batch, cancellationToken).ConfigureAwait(false); + } + return document; } - private async Task ProcessAsync(IngestionDocumentImage image, CancellationToken cancellationToken) + private static bool ShouldProcess(IngestionDocumentImage img) => + img.Content.HasValue && !string.IsNullOrEmpty(img.MediaType) && string.IsNullOrEmpty(img.AlternativeText); + + private async Task ProcessAsync(List batch, CancellationToken cancellationToken) { - if (image.Content.HasValue && !string.IsNullOrEmpty(image.MediaType) - && string.IsNullOrEmpty(image.AlternativeText)) + List contents = new(batch.Count); + foreach (var image in batch) { - var response = await _chatClient.GetResponseAsync( - [ - _systemPrompt, - new(ChatRole.User, [new DataContent(image.Content.Value, image.MediaType!)]) - ], _chatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + contents.Add(new DataContent(image.Content!.Value, image.MediaType!)); + } + + try + { + ChatResponse response = await _options.ChatClient.GetResponseAsync( + [_systemPrompt, new(ChatRole.User, contents)], + _options.ChatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); - image.AlternativeText = response.Text; + if (response.Result.Length == contents.Count) + { + for (int i = 0; i < response.Result.Length; i++) + { + batch[i].AlternativeText = response.Result[i]; + } + } + else + { + _logger?.UnexpectedResultsCount(response.Result.Length, contents.Count); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + // Enricher failures should not fail the whole ingestion pipeline, as they are best-effort enhancements. + _logger?.UnexpectedEnricherFailure(ex); } } } diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/KeywordEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/KeywordEnricher.cs index 56a305e2a87..c12c805544d 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/KeywordEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/KeywordEnricher.cs @@ -2,13 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Frozen; using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Text; using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.DataIngestion; @@ -22,34 +20,26 @@ namespace Microsoft.Extensions.DataIngestion; public sealed class KeywordEnricher : IngestionChunkProcessor { private const int DefaultMaxKeywords = 5; -#if NET - private static readonly System.Buffers.SearchValues _illegalCharacters = System.Buffers.SearchValues.Create([';', ',']); -#else - private static readonly char[] _illegalCharacters = [';', ',']; -#endif - private readonly IChatClient _chatClient; - private readonly ChatOptions? _chatOptions; - private readonly FrozenSet? _predefinedKeywords; + private readonly EnricherOptions _options; private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; /// /// Initializes a new instance of the class. /// - /// The chat client used for keyword extraction. + /// The options for generating keywords. /// The set of predefined keywords for extraction. - /// Options for the chat client. /// The maximum number of keywords to extract. When not provided, it defaults to 5. /// The confidence threshold for keyword inclusion. When not provided, it defaults to 0.7. /// /// If no predefined keywords are provided, the model will extract keywords based on the content alone. /// Such results may vary more significantly between different AI models. /// - public KeywordEnricher(IChatClient chatClient, ReadOnlySpan predefinedKeywords, - ChatOptions? chatOptions = null, int? maxKeywords = null, double? confidenceThreshold = null) + public KeywordEnricher(EnricherOptions options, ReadOnlySpan predefinedKeywords, + int? maxKeywords = null, double? confidenceThreshold = null) { - _chatClient = Throw.IfNull(chatClient); - _chatOptions = chatOptions; - _predefinedKeywords = CreatePredfinedKeywords(predefinedKeywords); + _options = Throw.IfNull(options).Clone(); + Validate(predefinedKeywords); double threshold = confidenceThreshold.HasValue ? Throw.IfOutOfRange(confidenceThreshold.Value, 0.0, 1.0, nameof(confidenceThreshold)) @@ -58,6 +48,7 @@ public KeywordEnricher(IChatClient chatClient, ReadOnlySpan predefinedKe ? Throw.IfLessThanOrEqual(maxKeywords.Value, 0, nameof(maxKeywords)) : DefaultMaxKeywords; _systemPrompt = CreateSystemPrompt(keywordsCount, predefinedKeywords, threshold); + _logger = _options.LoggerFactory?.CreateLogger(); } /// @@ -66,70 +57,29 @@ public KeywordEnricher(IChatClient chatClient, ReadOnlySpan predefinedKe public static string MetadataKey => "keywords"; /// - public override async IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(chunks); - - await foreach (IngestionChunk chunk in chunks.WithCancellation(cancellationToken)) - { - // Structured response is not used here because it's not part of Microsoft.Extensions.AI.Abstractions. - var response = await _chatClient.GetResponseAsync( - [ - _systemPrompt, - new(ChatRole.User, chunk.Content) - ], _chatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); - -#pragma warning disable EA0009 // Use 'System.MemoryExtensions.Split' for improved performance - string[] keywords = response.Text.Split(';'); - if (_predefinedKeywords is not null) - { - foreach (var keyword in keywords) - { - if (!_predefinedKeywords.Contains(keyword)) - { - throw new InvalidOperationException($"The extracted keyword '{keyword}' is not in the predefined keywords list."); - } - } - } + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); - chunk.Metadata[MetadataKey] = keywords; - - yield return chunk; - } - } - - private static FrozenSet? CreatePredfinedKeywords(ReadOnlySpan predefinedKeywords) + private static void Validate(ReadOnlySpan predefinedKeywords) { if (predefinedKeywords.Length == 0) { - return null; + return; } HashSet result = new(StringComparer.Ordinal); foreach (string keyword in predefinedKeywords) { -#if NET - if (keyword.AsSpan().ContainsAny(_illegalCharacters)) -#else - if (keyword.IndexOfAny(_illegalCharacters) >= 0) -#endif - { - Throw.ArgumentException(nameof(predefinedKeywords), $"Predefined keyword '{keyword}' contains an invalid character (';' or ',')."); - } - if (!result.Add(keyword)) { Throw.ArgumentException(nameof(predefinedKeywords), $"Duplicate keyword found: '{keyword}'"); } } - - return result.ToFrozenSet(StringComparer.Ordinal); } private static ChatMessage CreateSystemPrompt(int maxKeywords, ReadOnlySpan predefinedKeywords, double confidenceThreshold) { - StringBuilder sb = new($"You are a keyword extraction expert. Analyze the given text and extract up to {maxKeywords} most relevant keywords. "); + StringBuilder sb = new($"You are a keyword extraction expert. For each of the following texts, extract up to {maxKeywords} most relevant keywords. "); if (predefinedKeywords.Length > 0) { @@ -152,7 +102,6 @@ private static ChatMessage CreateSystemPrompt(int maxKeywords, ReadOnlySpan public sealed class SentimentEnricher : IngestionChunkProcessor { - private readonly IChatClient _chatClient; - private readonly ChatOptions? _chatOptions; - private readonly FrozenSet _validSentiments = -#if NET9_0_OR_GREATER - FrozenSet.Create(StringComparer.Ordinal, "Positive", "Negative", "Neutral", "Unknown"); -#else - new string[] { "Positive", "Negative", "Neutral", "Unknown" }.ToFrozenSet(StringComparer.Ordinal); -#endif + private readonly EnricherOptions _options; private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; /// /// Initializes a new instance of the class. /// - /// The chat client used for sentiment analysis. - /// Options for the chat client. + /// The options for sentiment analysis. /// The confidence threshold for sentiment determination. When not provided, it defaults to 0.7. - public SentimentEnricher(IChatClient chatClient, ChatOptions? chatOptions = null, double? confidenceThreshold = null) + public SentimentEnricher(EnricherOptions options, double? confidenceThreshold = null) { - _chatClient = Throw.IfNull(chatClient); - _chatOptions = chatOptions; + _options = Throw.IfNull(options).Clone(); double threshold = confidenceThreshold.HasValue ? Throw.IfOutOfRange(confidenceThreshold.Value, 0.0, 1.0, nameof(confidenceThreshold)) : 0.7; string prompt = $""" - You are a sentiment analysis expert. Analyze the sentiment of the given text and return Positive/Negative/Neutral or - Unknown when confidence score is below {threshold}. Return just the value of the sentiment. + You are a sentiment analysis expert. For each of the following texts, analyze the sentiment and return Positive/Negative/Neutral or + Unknown when confidence score is below {threshold}. """; _systemPrompt = new(ChatRole.System, prompt); + _logger = _options.LoggerFactory?.CreateLogger(); } /// @@ -56,27 +47,6 @@ Unknown when confidence score is below {threshold}. Return just the value of the public static string MetadataKey => "sentiment"; /// - public override async IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(chunks); - - await foreach (var chunk in chunks.WithCancellation(cancellationToken)) - { - var response = await _chatClient.GetResponseAsync( - [ - _systemPrompt, - new(ChatRole.User, chunk.Content) - ], _chatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); - - if (!_validSentiments.Contains(response.Text)) - { - throw new InvalidOperationException($"Invalid sentiment response: '{response.Text}'."); - } - - chunk.Metadata[MetadataKey] = response.Text; - - yield return chunk; - } - } + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); } diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SummaryEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SummaryEnricher.cs index f91b9809b05..7e2da4d12f5 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SummaryEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SummaryEnricher.cs @@ -3,10 +3,9 @@ using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.DataIngestion; @@ -19,23 +18,22 @@ namespace Microsoft.Extensions.DataIngestion; /// public sealed class SummaryEnricher : IngestionChunkProcessor { - private readonly IChatClient _chatClient; - private readonly ChatOptions? _chatOptions; + private readonly EnricherOptions _options; private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; /// /// Initializes a new instance of the class. /// - /// The chat client used for summary generation. - /// Options for the chat client. + /// The options for summary generation. /// The maximum number of words for the summary. When not provided, it defaults to 100. - public SummaryEnricher(IChatClient chatClient, ChatOptions? chatOptions = null, int? maxWordCount = null) + public SummaryEnricher(EnricherOptions options, int? maxWordCount = null) { - _chatClient = Throw.IfNull(chatClient); - _chatOptions = chatOptions; + _options = Throw.IfNull(options).Clone(); int wordCount = maxWordCount.HasValue ? Throw.IfLessThanOrEqual(maxWordCount.Value, 0, nameof(maxWordCount)) : 100; - _systemPrompt = new(ChatRole.System, $"Write a summary text for this text with no more than {wordCount} words. Return just the summary."); + _systemPrompt = new(ChatRole.System, $"For each of the following texts, write a summary text with no more than {wordCount} words."); + _logger = _options.LoggerFactory?.CreateLogger(); } /// @@ -44,22 +42,6 @@ public SummaryEnricher(IChatClient chatClient, ChatOptions? chatOptions = null, public static string MetadataKey => "summary"; /// - public override async IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(chunks); - - await foreach (var chunk in chunks.WithCancellation(cancellationToken)) - { - var response = await _chatClient.GetResponseAsync( - [ - _systemPrompt, - new(ChatRole.User, chunk.Content) - ], _chatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); - - chunk.Metadata[MetadataKey] = response.Text; - - yield return chunk; - } - } + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); } diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion/README.md new file mode 100644 index 00000000000..9886465cff6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/README.md @@ -0,0 +1,34 @@ +# Microsoft.Extensions.DataIngestion + +.NET developers need to efficiently process, chunk, and retrieve information from diverse document formats while preserving semantic meaning and structural context. The `Microsoft.Extensions.DataIngestion` libraries provide a unified approach for representing document ingestion components. + +## The packages + +The [Microsoft.Extensions.DataIngestion.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion.Abstractions) package provides the core exchange types, including [`IngestionDocument`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestiondocument), [`IngestionChunker`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunker-1), [`IngestionChunkProcessor`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkprocessor-1), and [`IngestionChunkWriter`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkwriter-1). Any .NET library that provides document processing capabilities can implement these abstractions to enable seamless integration with consuming code. + +The [Microsoft.Extensions.DataIngestion](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion) package has an implicit dependency on the `Microsoft.Extensions.DataIngestion.Abstractions` package. This package enables you to easily integrate components such as enrichment processors, vector storage writers, and telemetry into your applications using familiar dependency injection and pipeline patterns. For example, it provides the [`SentimentEnricher`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.sentimentenricher), [`KeywordEnricher`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.keywordenricher), and [`SummaryEnricher`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.summaryenricher) processors that can be chained together in ingestion pipelines. + +## Which package to reference + +Libraries that provide implementations of the abstractions typically reference only `Microsoft.Extensions.DataIngestion.Abstractions`. + +To also have access to higher-level utilities for working with document ingestion components, reference the `Microsoft.Extensions.DataIngestion` package instead (which itself references `Microsoft.Extensions.DataIngestion.Abstractions`). Most consuming applications and services should reference the `Microsoft.Extensions.DataIngestion` package along with one or more libraries that provide concrete implementations of the abstractions, such as `Microsoft.Extensions.DataIngestion.MarkItDown` or `Microsoft.Extensions.DataIngestion.Markdig`. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion --prerelease +``` +Or directly in the C# project file: + +```xml + + + +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Utils/Batching.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Utils/Batching.cs new file mode 100644 index 00000000000..b210019401b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Utils/Batching.cs @@ -0,0 +1,108 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +#if NET10_0_OR_GREATER +using System.Linq; +#endif +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +internal static class Batching +{ + internal static async IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, + EnricherOptions options, + string metadataKey, + ChatMessage systemPrompt, + ILogger? logger, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + where TMetadata : notnull + { + _ = Throw.IfNull(chunks); + + await foreach (var batch in chunks.Chunk(options.BatchSize).WithCancellation(cancellationToken)) + { + List contents = new(batch.Length); + foreach (var chunk in batch) + { + contents.Add(new TextContent(chunk.Content)); + } + + try + { + ChatResponse response = await options.ChatClient.GetResponseAsync( + [ + systemPrompt, + new(ChatRole.User, contents) + ], options.ChatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (response.Result.Length == contents.Count) + { + for (int i = 0; i < response.Result.Length; i++) + { + batch[i].Metadata[metadataKey] = response.Result[i]; + } + } + else + { + logger?.UnexpectedResultsCount(response.Result.Length, contents.Count); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + // Enricher failures should not fail the whole ingestion pipeline, as they are best-effort enhancements. + logger?.UnexpectedEnricherFailure(ex); + } + + foreach (var chunk in batch) + { + yield return chunk; + } + } + } + +#if !NET10_0_OR_GREATER +#pragma warning disable VSTHRD200 // Use "Async" suffix for async methods + private static IAsyncEnumerable Chunk(this IAsyncEnumerable source, int count) +#pragma warning restore VSTHRD200 // Use "Async" suffix for async methods + { + _ = Throw.IfNull(source); + _ = Throw.IfLessThanOrEqual(count, 0); + + return CoreAsync(source, count); + + static async IAsyncEnumerable CoreAsync(IAsyncEnumerable source, int count, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var buffer = new TSource[count]; + int index = 0; + + await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + buffer[index++] = item; + + if (index == count) + { + index = 0; + yield return buffer; + } + } + + if (index > 0) + { + Array.Resize(ref buffer, index); + yield return buffer; + } + } + } +#endif +} diff --git a/src/ProjectTemplates/.gitignore b/src/ProjectTemplates/.gitignore index ceffca4abbc..ee4a515c3d2 100644 --- a/src/ProjectTemplates/.gitignore +++ b/src/ProjectTemplates/.gitignore @@ -12,8 +12,8 @@ package-lock.json */src/**/Directory.Build.props */src/**/ingestioncache.* -# launchSettings.json files are required for the templates. -!launchSettings.json +# The project templates include hard-coded launchSettings.json files +!*/src/ProjectTemplates/**/Properties/launchSettings.json # Templates include JS dependencies in dist folders. !**/dist/* diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index 403beadc3a0..9014b892d98 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -9,6 +9,7 @@ --> <_LocalChatTemplateVariant>aspire + <_WebApiAgentRoot>$(MSBuildThisFileDirectory)Microsoft.Agents.AI.ProjectTemplates\src\WebApiAgent\ <_ChatWithCustomDataContentRoot>$(MSBuildThisFileDirectory)Microsoft.Extensions.AI.Templates\src\ChatWithCustomData\ <_McpServerContentRoot>$(MSBuildThisFileDirectory)Microsoft.Extensions.AI.Templates\src\McpServer\ @@ -25,6 +26,9 @@ + + + @@ -33,6 +37,8 @@ Specifies external packages that get referenced in generated template content. --> + 1.0.0-preview.251110.2 + 1.0.0-alpha.251110.2 9.5.1 9.5.1-preview.1.25502.11 1.0.0 @@ -41,8 +47,9 @@ 9.8.1-beta.413 10.0.0-rc.2.25502.107 9.5.1 - 1.66.0 - 1.66.0-preview + 2.0.0-preview.25503.2 + 1.67.1 + 1.67.1-preview 0.4.0-preview.1 5.4.8 1.13.0 @@ -59,6 +66,8 @@ ArtifactsShippingPackagesDir=$(ArtifactsShippingPackagesDir); + TemplatePackageVersion_MicrosoftAgentsAI=$(TemplatePackageVersion_MicrosoftAgentsAI); + TemplatePackageVersion_MicrosoftAgentsAIHostingOpenAI=$(TemplatePackageVersion_MicrosoftAgentsAIHostingOpenAI); TemplatePackageVersion_Aspire=$(TemplatePackageVersion_Aspire); TemplatePackageVersion_Aspire_Preview=$(TemplatePackageVersion_Aspire_Preview); TemplatePackageVersion_AzureAIProjects=$(TemplatePackageVersion_AzureAIProjects); @@ -67,6 +76,7 @@ TemplatePackageVersion_CommunityToolkitAspire=$(TemplatePackageVersion_CommunityToolkitAspire); TemplatePackageVersion_MicrosoftExtensionsHosting=$(TemplatePackageVersion_MicrosoftExtensionsHosting); TemplatePackageVersion_MicrosoftExtensionsServiceDiscovery=$(TemplatePackageVersion_MicrosoftExtensionsServiceDiscovery); + TemplatePackageVersion_MicrosoftMLTokenizers=$(TemplatePackageVersion_MicrosoftMLTokenizers); TemplatePackageVersion_MicrosoftSemanticKernel=$(TemplatePackageVersion_MicrosoftSemanticKernel); TemplatePackageVersion_MicrosoftSemanticKernel_Preview=$(TemplatePackageVersion_MicrosoftSemanticKernel_Preview); TemplatePackageVersion_ModelContextProtocol=$(TemplatePackageVersion_ModelContextProtocol); @@ -81,6 +91,9 @@ + diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj new file mode 100644 index 00000000000..62bebc3ee06 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj @@ -0,0 +1,62 @@ + + + + Template + $(NetCoreTargetFrameworks) + Project templates for Microsoft.Agents.AI. + dotnet-new;templates;ai;agent + + preview + + + 1 + 0 + 0 + preview + preview.251110.2 + + AI + 0 + 0 + + true + false + true + false + false + content + false + true + true + + + + + + + + + + + + + + + + diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/README.md b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/README.md new file mode 100644 index 00000000000..0df92140794 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/README.md @@ -0,0 +1,3 @@ +# Microsoft.Agents.AI.ProjectTemplates + +Provides project templates for Microsoft.Agents.AI. diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/THIRD-PARTY-NOTICES.TXT b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/THIRD-PARTY-NOTICES.TXT new file mode 100644 index 00000000000..f5eee59e294 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/THIRD-PARTY-NOTICES.TXT @@ -0,0 +1,64 @@ +.NET Core uses third-party libraries or other resources that may be +distributed under licenses different than the .NET Core software. + +In the event that we accidentally failed to list a required notice, please +bring it to our attention. Post an issue or email us: + + dotnet@microsoft.com + +The attached notices are provided for information only. + +License notice for OllamaSharp +------------------------------ + +https://github.com/awaescher/OllamaSharp + +MIT License + +Copyright (c) 2024 Andreas Wäscher + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +License notice for openai-dotnet +------------------------- + +https://github.com/openai/openai-dotnet + +The MIT License (MIT) + +Copyright (c) 2024 OpenAI (https://openai.com) + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/dotnetcli.host.json b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/dotnetcli.host.json new file mode 100644 index 00000000000..94096fec7c7 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/dotnetcli.host.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json.schemastore.org/dotnetcli.host", + "symbolInfo": { + "Framework": { + "longName": "framework" + }, + "AiServiceProvider": { + "longName": "provider", + "shortName": "" + }, + "ChatModel": { + "longName": "chat-model", + "shortName": "" + }, + "UseManagedIdentity": { + "longName": "managed-identity", + "shortName": "" + }, + "httpPort": { + "isHidden": "true", + "longName": "httpPort", + "shortName": "" + }, + "httpsPort": { + "isHidden": "true", + "longName": "httpsPort", + "shortName": "" + } + }, + "usageExamples": [ + "" + ] +} diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/ide.host.json b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/ide.host.json new file mode 100644 index 00000000000..28b30424345 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/ide.host.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/ide.host", + "order": 1, + "icon": "ide/agent-framework.ico", + "displayOverviewPage": "", + "symbolInfo": [ + { + "id": "AiServiceProvider", + "isVisible": true + }, + { + "id": "ChatModel", + "isVisible": false + }, + { + "id": "UseManagedIdentity", + "isVisible": true + } + ] +} diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/ide/agent-framework.ico b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/ide/agent-framework.ico new file mode 100644 index 00000000000..b644a8f3627 Binary files /dev/null and b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/ide/agent-framework.ico differ diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/template.json b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/template.json new file mode 100644 index 00000000000..c4ef244e754 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/.template.config/template.json @@ -0,0 +1,218 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Microsoft", + "classifications": [ "Common", "AI", "API", "Web", "Web API", "WebAPI", "Service" ], + "identity": "Microsoft.Agents.AI.ProjectTemplates.WebApiAgent.CSharp", + "name": "AI Agent Web API", + "description": "A project template for creating an AI Agent Web API application.", + "shortName": "aiagent-webapi", + "defaultName": "WebApiAgent", + "sourceName": "WebApiAgent-CSharp", + "preferNameDirectory": true, + "tags": { + "language": "C#", + "type": "project" + }, + "guids": [ + "c7e8f3d2-1a4b-5c6d-8e9f-0a1b2c3d4e5f" + ], + "primaryOutputs": [ + { + "path": "./README.md" + }, + { + "path": "./WebApiAgent-CSharp.csproj" + } + ], + "sources": [ + { + "source": "./", + "target": "./" + } + ], + "symbols": { + "Framework": { + "type": "parameter", + "description": "The target framework for the project.", + "datatype": "choice", + "choices": [ + { + "choice": "net10.0", + "description": ".NET 10" + }, + { + "choice": "net9.0", + "description": ".NET 9" + }, + { + "choice": "net8.0", + "description": ".NET 8" + } + ], + "replaces": "net10.0", + "defaultValue": "net10.0", + "displayName": "Framework" + }, + "hostIdentifier": { + "type": "bind", + "binding": "HostIdentifier" + }, + "AiServiceProvider": { + "type": "parameter", + "displayName": "_AI service provider", + "datatype": "choice", + "defaultValue": "githubmodels", + "choices": [ + { + "choice": "azureopenai", + "displayName": "Azure OpenAI", + "description": "Uses Azure OpenAI service" + }, + { + "choice": "githubmodels", + "displayName": "GitHub Models", + "description": "Uses GitHub Models" + }, + { + "choice": "ollama", + "displayName": "Ollama (for local development)", + "description": "Uses Ollama with the llama3.2 model" + }, + { + "choice": "openai", + "displayName": "OpenAI Platform", + "description": "Uses the OpenAI Platform" + } + ] + }, + "UseManagedIdentity": { + "type": "parameter", + "displayName": "Use keyless authentication for Azure services", + "datatype": "bool", + "defaultValue": "true", + "isEnabled": "(AiServiceProvider == \"azureopenai\")", + "description": "Use managed identity to access Azure services" + }, + "ChatModel": { + "type": "parameter", + "displayName": "Model/deployment for chat completions. Example: gpt-4o-mini", + "description": "Model/deployment for chat completions. Example: gpt-4o-mini" + }, + "IsManagedIdentity": { + "type": "computed", + "value": "(UseManagedIdentity)" + }, + "IsAzureOpenAI": { + "type": "computed", + "value": "(AiServiceProvider == \"azureopenai\")" + }, + "IsOpenAI": { + "type": "computed", + "value": "(AiServiceProvider == \"openai\")" + }, + "IsGHModels": { + "type": "computed", + "value": "(AiServiceProvider == \"githubmodels\")" + }, + "IsOllama": { + "type": "computed", + "value": "(AiServiceProvider == \"ollama\")" + }, + "OpenAiChatModelDefault": { + "type": "generated", + "generator": "constant", + "parameters": { + "value": "gpt-4o-mini" + } + }, + "OpenAiChatModel": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "ChatModel", + "fallbackVariableName": "OpenAiChatModelDefault" + }, + "replaces": "gpt-4o-mini" + }, + "OllamaChatModelDefault": { + "type": "generated", + "generator": "constant", + "parameters": { + "value": "llama3.2" + } + }, + "OllamaChatModel": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "ChatModel", + "fallbackVariableName": "OllamaChatModelDefault" + }, + "replaces": "llama3.2" + }, + "httpPort": { + "type": "parameter", + "datatype": "integer", + "description": "Port number to use for the HTTP endpoint in launchSettings.json." + }, + "httpPortGenerated": { + "type": "generated", + "generator": "port", + "parameters": { + "low": 5000, + "high": 5300 + } + }, + "httpPortReplacer": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "httpPort", + "fallbackVariableName": "httpPortGenerated" + }, + "replaces": "5056", + "onlyIf": [ + { + "after": "localhost:" + } + ] + }, + "httpsPort": { + "type": "parameter", + "datatype": "integer", + "description": "Port number to use for the HTTPS endpoint in launchSettings.json." + }, + "httpsPortGenerated": { + "type": "generated", + "generator": "port", + "parameters": { + "low": 7000, + "high": 7300 + } + }, + "httpsPortReplacer": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "httpsPort", + "fallbackVariableName": "httpsPortGenerated" + }, + "replaces": "7041", + "onlyIf": [ + { + "after": "localhost:" + } + ] + } + }, + "postActions": [{ + "condition": "(hostIdentifier != \"dotnetcli\" && hostIdentifier != \"dotnetcli-preview\")", + "description": "Opens README file in the editor", + "manualInstructions": [ ], + "actionId": "84C0DA21-51C8-4541-9940-6CA19AF04EE6", + "args": { + "files": "0" + }, + "continueOnError": true + }] +} diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/Program.cs new file mode 100644 index 00000000000..743658f6c8a --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/Program.cs @@ -0,0 +1,118 @@ +#if (IsGHModels || IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) +using System.ClientModel; +#elif (IsAzureOpenAI && IsManagedIdentity) +using System.ClientModel.Primitives; +#endif +using System.ComponentModel; +#if (IsAzureOpenAI && IsManagedIdentity) +using Azure.Identity; +#endif +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DevUI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +#if (IsOllama) +using OllamaSharp; +#endif +#if (IsGHModels || IsAzureOpenAI) +using OpenAI; +#endif +#if (IsGHModels || IsOpenAI || IsAzureOpenAI) +using OpenAI.Chat; +#endif + +var builder = WebApplication.CreateBuilder(args); + +#if (IsGHModels) +// You will need to set the token to your own value +// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: +// cd this-project-directory +// dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" +var chatClient = new ChatClient( + "gpt-4o-mini", + new ApiKeyCredential(builder.Configuration["GITHUB_TOKEN"] ?? throw new InvalidOperationException("Missing configuration: GITHUB_TOKEN")), + new OpenAIClientOptions { Endpoint = new Uri("https://models.inference.ai.azure.com") }) + .AsIChatClient(); +#elif (IsOpenAI) +// You will need to set the API key to your own value +// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: +// cd this-project-directory +// dotnet user-secrets set "OPENAI_KEY" "your-openai-api-key-here" +var chatClient = new ChatClient( + "gpt-4o-mini", + new ApiKeyCredential(builder.Configuration["OPENAI_KEY"] ?? throw new InvalidOperationException("Missing configuration: OPENAI_KEY"))) + .AsIChatClient(); +#elif (IsAzureOpenAI) +// You will need to set the endpoint to your own value +// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: +// cd this-project-directory +// dotnet user-secrets set AzureOpenAI:Endpoint https://YOUR-DEPLOYMENT-NAME.openai.azure.com +#if (!IsManagedIdentity) +// dotnet user-secrets set AzureOpenAI:Key YOUR-API-KEY +#endif +var azureOpenAIEndpoint = new Uri(new Uri(builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAI:Endpoint")), "/openai/v1"); + +#if (IsManagedIdentity) +#pragma warning disable OPENAI001 // The overload accepting an AuthenticationPolicy is experimental and may change or be removed in future releases. +var chatClient = new ChatClient( + "gpt-4o-mini", + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }) + .AsIChatClient(); +#pragma warning restore OPENAI001 +#else +var chatClient = new ChatClient( + "gpt-4o-mini", + new ApiKeyCredential(builder.Configuration["AzureOpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAI:Key")), + new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }) + .AsIChatClient(); +#endif +#elif (IsOllama) +// You will need to have Ollama running locally with the llama3.2 model installed +// Visit https://ollama.com for installation instructions +var chatClient = new OllamaApiClient(new Uri("http://localhost:11434"), "llama3.2"); +#endif + +builder.Services.AddChatClient(chatClient); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] +)); + +builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( + workflowName: key, + sp.GetRequiredKeyedService("writer"), + sp.GetRequiredKeyedService("editor") +)).AddAsAIAgent(); + +// Register services for OpenAI responses and conversations (also required for DevUI) +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); +app.UseHttpsRedirection(); + +// Map endpoints for OpenAI responses and conversations (also required for DevUI) +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +if (builder.Environment.IsDevelopment()) +{ + // Map DevUI endpoint to /devui + app.MapDevUI(); +} + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/Properties/launchSettings.json b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/Properties/launchSettings.json new file mode 100644 index 00000000000..27165ad4a22 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "http://localhost:5056", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "https://localhost:7041;http://localhost:5056", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/README.md b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/README.md new file mode 100644 index 00000000000..a8811969839 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/README.md @@ -0,0 +1,295 @@ +# AI Agent Web API + +This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). + +## Prerequisites + + +- A GitHub Models API token (free to get started) + +- An OpenAI API key + +- An Azure OpenAI service deployment + +- Ollama installed locally with the llama3.2 model + + +## Getting Started + +### 1. Configure Your AI Service + + +#### GitHub Models Configuration + +This application uses GitHub Models (model: gpt-4o-mini) for AI functionality. You'll need to configure your GitHub Models API token: + +**Option A: Using User Secrets (Recommended for Development)** + +```bash +dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" +``` + +**Option B: Using Environment Variables** + +Set the `GITHUB_TOKEN` environment variable: + +- **Windows (PowerShell)**: + ```powershell + $env:GITHUB_TOKEN = "your-github-models-token-here" + ``` + +- **Linux/macOS**: + ```bash + export GITHUB_TOKEN="your-github-models-token-here" + ``` + +#### Get a GitHub Models Token + +1. Visit [GitHub Models](https://github.com/marketplace/models) +2. Sign in with your GitHub account +3. Select a model (e.g., gpt-4o-mini) +4. Click "Get API Key" or follow the authentication instructions +5. Copy your personal access token + + +#### OpenAI Configuration + +This application uses the OpenAI Platform (model: gpt-4o-mini). You'll need to configure your OpenAI API key: + +**Using User Secrets (Recommended for Development)** + +```bash +dotnet user-secrets set "OPENAI_KEY" "your-openai-api-key-here" +``` + +**Using Environment Variables** + +Set the `OPENAI_KEY` environment variable: + +- **Windows (PowerShell)**: + ```powershell + $env:OPENAI_KEY = "your-openai-api-key-here" + ``` + +- **Linux/macOS**: + ```bash + export OPENAI_KEY="your-openai-api-key-here" + ``` + +#### Get an OpenAI API Key + +1. Visit [OpenAI Platform](https://platform.openai.com) +2. Sign in or create an account +3. Navigate to API Keys +4. Create a new API key +5. Copy your API key + + +#### Azure OpenAI Configuration + +This application uses Azure OpenAI service (model: gpt-4o-mini). You'll need to configure your Azure OpenAI endpoint and API key: + +**Using User Secrets (Recommended for Development)** + +```bash +dotnet user-secrets set "AzureOpenAI:Endpoint" "https://YOUR-DEPLOYMENT-NAME.openai.azure.com" + +dotnet user-secrets set "AzureOpenAI:Key" "your-azure-openai-key-here" + +``` + +**Using Environment Variables** + +- **Windows (PowerShell)**: + ```powershell + $env:AzureOpenAI__Endpoint = "https://YOUR-DEPLOYMENT-NAME.openai.azure.com" + + $env:AzureOpenAI__Key = "your-azure-openai-key-here" + + ``` + +- **Linux/macOS**: + ```bash + export AzureOpenAI__Endpoint="https://YOUR-DEPLOYMENT-NAME.openai.azure.com" + + export AzureOpenAI__Key="your-azure-openai-key-here" + + ``` + + +#### Managed Identity Authentication + +This application is configured to use Azure Managed Identity for authentication. When deploying to Azure: + +1. Ensure your Azure resource (App Service, Container Apps, etc.) has a managed identity enabled +2. Grant the managed identity access to your Azure OpenAI resource with the "Cognitive Services OpenAI User" role +3. No API key configuration is needed + +For local development, ensure you're signed in to Azure CLI or have configured DefaultAzureCredential appropriately. + + +#### Set Up Azure OpenAI + +1. Visit [Azure Portal](https://portal.azure.com) +2. Create an Azure OpenAI resource +3. Deploy a model (e.g., gpt-4o-mini) +4. Copy your endpoint and API key + + +#### Ollama Configuration + +This application uses Ollama running locally (model: llama3.2). You'll need to have Ollama installed and the llama3.2 model downloaded: + +1. Visit [Ollama](https://ollama.com) and follow the installation instructions for your platform +2. Once installed, download the llama3.2 model: + ```bash + ollama pull llama3.2 + ``` +3. Ensure Ollama is running (it starts automatically after installation) + +The application is configured to connect to Ollama at `http://localhost:11434`. + + + +### 2. Run the Application + +```bash +dotnet run -lp https +``` + +The application will start and listen on: +- HTTP: `http://localhost:5056` +- HTTPS: `https://localhost:7041` + +### 3. Test the Application + +The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. + +In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. + +## How It Works + +This application demonstrates Agent Framework with: + +1. **Writer Agent**: Writes short stories (300 words or less) about specified topics +2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words +3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents + +The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. + +## Template Parameters + +When creating a new project, you can customize it using template parameters: + +```bash +# Specify AI service provider +dotnet new aiagent-webapi --provider azureopenai + +# Specify a custom chat model +dotnet new aiagent-webapi --chat-model gpt-4o + +# Use API key authentication for Azure OpenAI +dotnet new aiagent-webapi --provider azureopenai --managed-identity false + +# Use Ollama with a different model +dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 +``` + +### Available Parameters + +- **`--provider`**: Choose the AI service provider + - `githubmodels` (default) - GitHub Models + - `azureopenai` - Azure OpenAI + - `openai` - OpenAI Platform + - `ollama` - Ollama (local development) + +- **`--chat-model`**: Specify the chat model/deployment name + - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for Ollama: `llama3.2` + +- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) + - Only applicable when `--provider azureopenai` + +- **`--framework`**: Target framework (default: `net10.0`) + - Options: `net10.0`, `net9.0`, `net8.0` + +## Project Structure + +- `Program.cs` - Application entry point and configuration +- `appsettings.json` - Application configuration +- `Properties/launchSettings.json` - Launch profiles for development + +## Learn More + +- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) +- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) + +- [GitHub Models](https://github.com/marketplace/models) + +- [OpenAI Platform](https://platform.openai.com) + +- [Azure OpenAI Service](https://azure.microsoft.com/products/ai-services/openai-service) + +- [Ollama](https://ollama.com) + + +## Troubleshooting + + +**Problem**: Application fails with "Missing configuration: GITHUB_TOKEN" + +**Solution**: Make sure you've configured your GitHub Models API token using one of the methods described above. + +**Problem**: API requests fail with authentication errors + +**Solution**: Verify your GitHub Models token is valid and hasn't expired. You may need to regenerate it from the GitHub Models website. + + +**Problem**: Application fails with "Missing configuration: OPENAI_KEY" + +**Solution**: Make sure you've configured your OpenAI API key using one of the methods described above. + +**Problem**: API requests fail with authentication errors + +**Solution**: Verify your OpenAI API key is valid. Check your usage limits and billing status on the OpenAI Platform. + + + +**Problem**: Application fails with "Missing configuration: AzureOpenAI:Endpoint" or "Missing configuration: AzureOpenAI:Key" + +**Solution**: Make sure you've configured your Azure OpenAI endpoint and API key using one of the methods described above. + +**Problem**: API requests fail with authentication errors + +**Solution**: Verify your Azure OpenAI endpoint is correct and your API key is valid. + + +**Problem**: Application fails with "Missing configuration: AzureOpenAI:Endpoint" + +**Solution**: Make sure you've configured your Azure OpenAI endpoint using one of the methods described above. + +**Problem**: Managed identity authentication fails + +**Solution**: +- Ensure your Azure resource has a system-assigned or user-assigned managed identity enabled +- Verify the managed identity has been granted the "Cognitive Services OpenAI User" role on your Azure OpenAI resource +- For local development, ensure you're signed in to Azure CLI: `az login` + +**Problem**: API requests fail with authentication errors + +**Solution**: Verify your Azure OpenAI endpoint is correct and your managed identity has the correct permissions. + + + + +**Problem**: Application fails to connect to Ollama + +**Solution**: +- Ensure Ollama is running. On macOS/Linux, check with `pgrep ollama`. On Windows, check Task Manager. +- Verify Ollama is accessible at `http://localhost:11434` +- Make sure you've downloaded the llama3.2 model: `ollama pull llama3.2` + +**Problem**: Model responses are slow or time out + +**Solution**: Ollama runs locally and performance depends on your hardware. Consider using a smaller model or ensuring your system has adequate resources (RAM, GPU if available). + diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/WebApiAgent-CSharp.csproj.in b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/WebApiAgent-CSharp.csproj.in new file mode 100644 index 00000000000..a0363fb6848 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/WebApiAgent-CSharp.csproj.in @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + c7e8f3d2-1a4b-5c6d-8e9f-0a1b2c3d4e5f + + + + + + + + + + + + + + + + + + + + diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/appsettings.json b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/src/WebApiAgent/WebApiAgent-CSharp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/THIRD-PARTY-NOTICES.TXT b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/THIRD-PARTY-NOTICES.TXT index 232d8d463eb..0fb44e61538 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/THIRD-PARTY-NOTICES.TXT +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/THIRD-PARTY-NOTICES.TXT @@ -817,267 +817,6 @@ https://github.com/mozilla/pdf.js END OF TERMS AND CONDITIONS -License notice for PdfPig -------------------------- - -https://github.com/UglyToad/PdfPig - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - -EXTERNAL COMPONENTS - -PdfPig includes a number of components with separate copyright notices -and license terms. Your use of these components is subject to the terms and -conditions of the following licenses. - -Contributions made to the original PDFBox and FontBox projects: - - Copyright (c) 2002-2007, www.pdfbox.org - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - 3. Neither the name of pdfbox; nor the names of its contributors may be - used to endorse or promote products derived from this software without - specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE - FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT - LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY - OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF - SUCH DAMAGE. - -Adobe Font Metrics (AFM) for PDF Core 14 Fonts - - This file and the 14 PostScript(R) AFM files it accompanies may be used, - copied, and distributed for any purpose and without charge, with or without - modification, provided that all copyright notices are retained; that the - AFM files are not distributed without this file; that all modifications - to this file or any of the AFM files are prominently noted in the modified - file(s); and that this paragraph is not modified. Adobe Systems has no - responsibility or obligation to support the use of the AFM files. - -CMaps for PDF Fonts (http://opensource.adobe.com/wiki/display/cmap/Downloads) - - Copyright 1990-2009 Adobe Systems Incorporated. - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions - are met: - - Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - Neither the name of Adobe Systems Incorporated nor the names of its - contributors may be used to endorse or promote products derived from this - software without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE - LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF - THE POSSIBILITY OF SUCH DAMAGE. - License notice for OllamaSharp ------------------------------ diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json index 603ed1a2735..54b38545be5 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json @@ -70,6 +70,12 @@ "ChatWithCustomData-CSharp.Web/OllamaResilienceHandlerExtensions.cs" ] }, + { + "condition": "(IsAspire)", + "exclude": [ + "ChatWithCustomData-CSharp.Web/Services/Ingestion/PdfPigReader.cs" + ] + }, { "condition": "(IsAspire)", "exclude": [ @@ -101,13 +107,17 @@ "description": "The target framework for the project.", "datatype": "choice", "choices": [ + { + "choice": "net10.0", + "description": ".NET 10" + }, { "choice": "net9.0", - "description": "Target net9.0" + "description": ".NET 9" } ], - "replaces": "net9.0", - "defaultValue": "net9.0", + "replaces": "net10.0", + "defaultValue": "net10.0", "displayName": "Framework" }, "hostIdentifier": { @@ -185,6 +195,10 @@ "defaultValue": "false", "description": "Create the project as a distributed application using Aspire." }, + "IsNET9": { + "type": "computed", + "value": "(Framework == \"net9.0\")" + }, "IsManagedIdentity": { "type": "computed", "value": "(UseManagedIdentity)" diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/AppHost.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/AppHost.cs index a859ce397a1..c1ca040558b 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/AppHost.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/AppHost.cs @@ -51,6 +51,10 @@ #else // IsLocalVectorStore #endif +var markitdown = builder.AddContainer("markitdown", "mcp/markitdown") + .WithArgs("--http", "--host", "0.0.0.0", "--port", "3001") + .WithHttpEndpoint(targetPort: 3001, name: "http"); + var webApp = builder.AddProject("aichatweb-app"); #if (IsOllama) // AI SERVICE PROVIDER REFERENCES webApp @@ -75,5 +79,7 @@ .WaitFor(vectorDB); #else // IsLocalVectorStore #endif +webApp + .WithEnvironment("MARKITDOWN_MCP_URL", markitdown.GetEndpoint("http")); builder.Build().Run(); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in index b7d8f13bdfa..8d0e2a3a011 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in @@ -4,7 +4,7 @@ Exe - net9.0 + net10.0 enable enable b2f4f5e9-1083-472c-8c3b-f055ac67ba54 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in index 3b67ba158cd..814c86384e7 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs index 204f7a64164..a32bbd5bcca 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs @@ -76,7 +76,8 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() .AddHttpClientInstrumentation() - .AddSource("Experimental.Microsoft.Extensions.AI"); + .AddSource("Experimental.Microsoft.Extensions.AI") + .AddSource("Experimental.Microsoft.Extensions.DataIngestion"); }); builder.AddOpenTelemetryExporters(); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in index 05e9a9726de..9368b96356f 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable d5681fae-b21b-4114-b781-48180f08c0c4 @@ -30,9 +30,18 @@ - + + + + + + + + + + win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64 diff --git a/src/ProjectTemplates/README.md b/src/ProjectTemplates/README.md index 5ac7bb096f8..f527c08d74e 100644 --- a/src/ProjectTemplates/README.md +++ b/src/ProjectTemplates/README.md @@ -1,4 +1,6 @@ -# Updating project template JavaScript dependencies +# Project Template Local Development and Maintenance + +## Updating project template JavaScript dependencies To update project template JavaScript dependencies: 1. Install a recent build of Node.js @@ -11,38 +13,38 @@ To update project template JavaScript dependencies: To add a new dependency, run `npm install ` and update the `scripts` section in `package.json` to specify how the new dependency should be copied into its template. -# Component governance +## Component governance There are two types of template dependencies that need to get scanned for component governance (CG): * .NET dependencies (specified via `` in each `.csproj` file) * JS dependencies (everything in the `wwwroot/lib` folder of the `.Web` project) -There are template execution tests in the `test/ProjectTemplates` folder of this repo that create, restore, and build each possible variation of the template. These tests execute before the CG step of the internal CI pipeline, which scans the build artifacts from each generated project (namely the `project.assets.json` file and the local NuGet package cache) to detect which .NET dependencies got pulled in. +There are template execution tests in the `test/ProjectTemplates` folders that create, restore, and build each possible variation of the project templates. These tests execute before the CG step of the internal CI pipeline, which scans the build artifacts from each generated project (namely the `project.assets.json` file and the local NuGet package cache) to detect which .NET dependencies got pulled in. However, CG can't detect JS dependencies by scanning execution test output, because the generated projects don't contain manifests describing JS dependencies. Instead, we have a `package.json` and `package-lock.json` in the same folder as this README that define which JS dependencies get included in the template and how they get copied into template content (see previous section in this document). CG then automatically tracks packages listed in this `package-lock.json`. -# Running AI templates - ## Build the templates using just-built library package versions By default the templates use just-built versions of library packages from this repository, so NuGet packages must be produced before the templates can be run: ```pwsh -.\build.cmd -vs AI -noLaunch # Generate an SDK.sln for Microsoft.Extensions.AI* projects -.\build.cmd -build -pack # Build a NuGet package for each project +.\build.cmd -vs AI -noLaunch # Generate an SDK.sln for projects matching the AI filter +.\build.cmd -build -pack # Build a NuGet package for each project in the generated SDK.sln ``` -Once the library packages are built, the `Microsoft.Extensions.AI.Templates` package is built with references to the local package versions using the following commands: +Once the library packages are built, the template packages can be built with references to the local package versions using the following commands: ```pwsh +.\build.cmd -pack -projects .\src\ProjectTemplates\Microsoft.Agents.AI.ProjectTemplates\Microsoft.Extensions.AI.Templates.csproj .\build.cmd -pack -projects .\src\ProjectTemplates\Microsoft.Extensions.AI.Templates\Microsoft.Extensions.AI.Templates.csproj ``` ## Build the templates using pinned library package versions -The templates can also be built to reference pinned versions of the library packages. This approach is used when the `Microsoft.Extensions.AI.Templates` package is updated off-cycle from the library packages. The pinned versions are hard-coded in the `GeneratedContent.targets` file in this directory. To build the templates package using the pinned versions, run: +The templates can also be built to reference pinned versions of the library packages. This approach is used when a templates package is updated off-cycle from the library packages. The pinned versions are hard-coded in the `GeneratedContent.targets` file in this directory. To build the templates package using the pinned versions, run: ```pwsh +.\build.cmd -pack -projects .\src\ProjectTemplates\Microsoft.Agents.AI.ProjectTemplates\Microsoft.Agents.AI.ProjectTemplates.csproj /p:TemplateUsePinnedPackageVersions=true .\build.cmd -pack -projects .\src\ProjectTemplates\Microsoft.Extensions.AI.Templates\Microsoft.Extensions.AI.Templates.csproj /p:TemplateUsePinnedPackageVersions=true ``` @@ -54,24 +56,33 @@ Setting `/p:TemplateUsePinnedPackageVersions=true` will apply three different ca ## Installing the templates locally -After building the templates package using one of the approaches above, it can be installed locally. **Note:** Since package versions don't change between local builds, the recommended steps include clearing the `Microsoft.Extensions.AI*` packages from your local nuget cache. +After building the templates package using one of the approaches above, it can be installed locally. **Note:** Since package versions don't change between local builds, the recommended steps include clearing the `Microsoft.Extensions.AI*` and `Microsoft.Agents.AI*` packages from your local nuget cache. **Note:** For the following commands to succeed, you'll need to either install a compatible .NET SDK globally or prepend the repo's generated `.dotnet` folder to the PATH environment variable. ```pwsh # Uninstall any existing version of the templates +dotnet new uninstall Microsoft.Agents.AI.ProjectTemplates dotnet new uninstall Microsoft.Extensions.AI.Templates -# Clear the Microsoft.Extensions.AI packages from the NuGet cache since the local package version does not change +# Clear the packages from the NuGet cache since the local package version does not change +Remove-Item ~\.nuget\packages\Microsoft.Agents.AI* -Recurse -Force Remove-Item ~\.nuget\packages\Microsoft.Extensions.AI* -Recurse -Force -# Install the template from the generated .nupkg file (in the artifacts/packages folder) +# Install the templates from the generated .nupkg file (in the artifacts/packages folder) +dotnet new install .\artifacts\packages\Debug\Shipping\Microsoft.Agents.AI.ProjectTemplates*.nupkg dotnet new install .\artifacts\packages\Debug\Shipping\Microsoft.Extensions.AI.Templates*.nupkg ``` Finally, create a project from the template and run it: ```pwsh +dotnet new aiagent-webapi ` + [--provider ] ` + [--managed-identity] + +# or + dotnet new aichatweb ` [--provider ] ` [--vector-store ] ` diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs deleted file mode 100644 index 5380d208259..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs +++ /dev/null @@ -1,505 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Collections.Generic; -using System.Diagnostics; -using System.Text.Json.Nodes; - -namespace System.Text.Json.Schema; - -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S1144 // Unused private types or members should be removed - -internal static partial class JsonSchemaExporter -{ - // Simple JSON schema representation taken from System.Text.Json - // https://github.com/dotnet/runtime/blob/50d6cad649aad2bfa4069268eddd16fd51ec5cf3/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchema.cs - private sealed class JsonSchema - { - public static JsonSchema CreateFalseSchema() => new(false); - public static JsonSchema CreateTrueSchema() => new(true); - - public JsonSchema() - { - } - - private JsonSchema(bool trueOrFalse) - { - _trueOrFalse = trueOrFalse; - } - - public bool IsTrue => _trueOrFalse is true; - public bool IsFalse => _trueOrFalse is false; - private readonly bool? _trueOrFalse; - - public string? Schema - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public string? Title - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public string? Description - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public string? Ref - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public string? Comment - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public JsonSchemaType Type - { - get; - set - { - VerifyMutable(); - field = value; - } - } = JsonSchemaType.Any; - - public string? Format - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public string? Pattern - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public JsonNode? Constant - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public List>? Properties - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public List? Required - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public JsonSchema? Items - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public JsonSchema? AdditionalProperties - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public JsonArray? Enum - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public JsonSchema? Not - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public List? AnyOf - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public bool HasDefaultValue - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public JsonNode? DefaultValue - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public int? MinLength - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public int? MaxLength - { - get; - set - { - VerifyMutable(); - field = value; - } - } - - public JsonSchemaExporterContext? GenerationContext { get; set; } - - public int KeywordCount - { - get - { - if (_trueOrFalse != null) - { - return 0; - } - - int count = 0; - Count(Schema != null); - Count(Ref != null); - Count(Comment != null); - Count(Title != null); - Count(Description != null); - Count(Type != JsonSchemaType.Any); - Count(Format != null); - Count(Pattern != null); - Count(Constant != null); - Count(Properties != null); - Count(Required != null); - Count(Items != null); - Count(AdditionalProperties != null); - Count(Enum != null); - Count(Not != null); - Count(AnyOf != null); - Count(HasDefaultValue); - Count(MinLength != null); - Count(MaxLength != null); - - return count; - - void Count(bool isKeywordSpecified) => count += isKeywordSpecified ? 1 : 0; - } - } - - public void MakeNullable() - { - if (_trueOrFalse != null) - { - return; - } - - if (Type != JsonSchemaType.Any) - { - Type |= JsonSchemaType.Null; - } - } - - public JsonNode ToJsonNode(JsonSchemaExporterOptions options) - { - if (_trueOrFalse is { } boolSchema) - { - return CompleteSchema((JsonNode)boolSchema); - } - - var objSchema = new JsonObject(); - - if (Schema != null) - { - objSchema.Add(JsonSchemaConstants.SchemaPropertyName, Schema); - } - - if (Title != null) - { - objSchema.Add(JsonSchemaConstants.TitlePropertyName, Title); - } - - if (Description != null) - { - objSchema.Add(JsonSchemaConstants.DescriptionPropertyName, Description); - } - - if (Ref != null) - { - objSchema.Add(JsonSchemaConstants.RefPropertyName, Ref); - } - - if (Comment != null) - { - objSchema.Add(JsonSchemaConstants.CommentPropertyName, Comment); - } - - if (MapSchemaType(Type) is JsonNode type) - { - objSchema.Add(JsonSchemaConstants.TypePropertyName, type); - } - - if (Format != null) - { - objSchema.Add(JsonSchemaConstants.FormatPropertyName, Format); - } - - if (Pattern != null) - { - objSchema.Add(JsonSchemaConstants.PatternPropertyName, Pattern); - } - - if (Constant != null) - { - objSchema.Add(JsonSchemaConstants.ConstPropertyName, Constant); - } - - if (Properties != null) - { - var properties = new JsonObject(); - foreach (KeyValuePair property in Properties) - { - properties.Add(property.Key, property.Value.ToJsonNode(options)); - } - - objSchema.Add(JsonSchemaConstants.PropertiesPropertyName, properties); - } - - if (Required != null) - { - var requiredArray = new JsonArray(); - foreach (string requiredProperty in Required) - { - requiredArray.Add((JsonNode)requiredProperty); - } - - objSchema.Add(JsonSchemaConstants.RequiredPropertyName, requiredArray); - } - - if (Items != null) - { - objSchema.Add(JsonSchemaConstants.ItemsPropertyName, Items.ToJsonNode(options)); - } - - if (AdditionalProperties != null) - { - objSchema.Add(JsonSchemaConstants.AdditionalPropertiesPropertyName, AdditionalProperties.ToJsonNode(options)); - } - - if (Enum != null) - { - objSchema.Add(JsonSchemaConstants.EnumPropertyName, Enum); - } - - if (Not != null) - { - objSchema.Add(JsonSchemaConstants.NotPropertyName, Not.ToJsonNode(options)); - } - - if (AnyOf != null) - { - JsonArray anyOfArray = new(); - foreach (JsonSchema schema in AnyOf) - { - anyOfArray.Add(schema.ToJsonNode(options)); - } - - objSchema.Add(JsonSchemaConstants.AnyOfPropertyName, anyOfArray); - } - - if (HasDefaultValue) - { - objSchema.Add(JsonSchemaConstants.DefaultPropertyName, DefaultValue); - } - - if (MinLength is int minLength) - { - objSchema.Add(JsonSchemaConstants.MinLengthPropertyName, (JsonNode)minLength); - } - - if (MaxLength is int maxLength) - { - objSchema.Add(JsonSchemaConstants.MaxLengthPropertyName, (JsonNode)maxLength); - } - - return CompleteSchema(objSchema); - - JsonNode CompleteSchema(JsonNode schema) - { - if (GenerationContext is { } context) - { - Debug.Assert(options.TransformSchemaNode != null, "context should only be populated if a callback is present."); - - // Apply any user-defined transformations to the schema. - return options.TransformSchemaNode!(context, schema); - } - - return schema; - } - } - - public static void EnsureMutable(ref JsonSchema schema) - { - switch (schema._trueOrFalse) - { - case false: - schema = new JsonSchema { Not = JsonSchema.CreateTrueSchema() }; - break; - case true: - schema = new JsonSchema(); - break; - } - } - - private static readonly JsonSchemaType[] _schemaValues = new JsonSchemaType[] - { - // NB the order of these values influences order of types in the rendered schema - JsonSchemaType.String, - JsonSchemaType.Integer, - JsonSchemaType.Number, - JsonSchemaType.Boolean, - JsonSchemaType.Array, - JsonSchemaType.Object, - JsonSchemaType.Null, - }; - - private void VerifyMutable() - { - Debug.Assert(_trueOrFalse is null, "Schema is not mutable"); - } - - private static JsonNode? MapSchemaType(JsonSchemaType schemaType) - { - if (schemaType is JsonSchemaType.Any) - { - return null; - } - - if (ToIdentifier(schemaType) is string identifier) - { - return identifier; - } - - var array = new JsonArray(); - foreach (JsonSchemaType type in _schemaValues) - { - if ((schemaType & type) != 0) - { - array.Add((JsonNode)ToIdentifier(type)!); - } - } - - return array; - - static string? ToIdentifier(JsonSchemaType schemaType) => schemaType switch - { - JsonSchemaType.Null => "null", - JsonSchemaType.Boolean => "boolean", - JsonSchemaType.Integer => "integer", - JsonSchemaType.Number => "number", - JsonSchemaType.String => "string", - JsonSchemaType.Array => "array", - JsonSchemaType.Object => "object", - _ => null, - }; - } - } - - [Flags] - private enum JsonSchemaType - { - Any = 0, // No type declared on the schema - Null = 1, - Boolean = 2, - Integer = 4, - Number = 8, - String = 16, - Array = 32, - Object = 64, - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs deleted file mode 100644 index 6d350dab026..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs +++ /dev/null @@ -1,425 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -#if !NET -using System.Linq; -#endif -using System.Reflection; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - -namespace System.Text.Json.Schema; - -internal static partial class JsonSchemaExporter -{ - private static class ReflectionHelpers - { - private const BindingFlags AllInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - private static PropertyInfo? _jsonTypeInfo_ElementType; - private static PropertyInfo? _jsonPropertyInfo_MemberName; - private static FieldInfo? _nullableConverter_ElementConverter_Generic; - private static FieldInfo? _enumConverter_Options_Generic; - private static FieldInfo? _enumConverter_NamingPolicy_Generic; - - public static bool IsBuiltInConverter(JsonConverter converter) => - converter.GetType().Assembly == typeof(JsonConverter).Assembly; - - public static Type GetElementType(JsonTypeInfo typeInfo) - { - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary, "TypeInfo must be of collection type"); - - // Uses reflection to access the element type encapsulated by a JsonTypeInfo. - if (_jsonTypeInfo_ElementType is null) - { - PropertyInfo? elementTypeProperty = typeof(JsonTypeInfo).GetProperty("ElementType", AllInstance); - _jsonTypeInfo_ElementType = Throw.IfNull(elementTypeProperty); - } - - return (Type)_jsonTypeInfo_ElementType.GetValue(typeInfo)!; - } - - public static string? GetMemberName(JsonPropertyInfo propertyInfo) - { - // Uses reflection to the member name encapsulated by a JsonPropertyInfo. - if (_jsonPropertyInfo_MemberName is null) - { - PropertyInfo? memberName = typeof(JsonPropertyInfo).GetProperty("MemberName", AllInstance); - _jsonPropertyInfo_MemberName = Throw.IfNull(memberName); - } - - return (string?)_jsonPropertyInfo_MemberName.GetValue(propertyInfo); - } - - public static JsonConverter GetElementConverter(JsonConverter nullableConverter) - { - // Uses reflection to access the element converter encapsulated by a nullable converter. - if (_nullableConverter_ElementConverter_Generic is null) - { - FieldInfo? genericFieldInfo = Type - .GetType("System.Text.Json.Serialization.Converters.NullableConverter`1, System.Text.Json")! - .GetField("_elementConverter", AllInstance); - - _nullableConverter_ElementConverter_Generic = Throw.IfNull(genericFieldInfo); - } - - Type converterType = nullableConverter.GetType(); - var thisFieldInfo = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_nullableConverter_ElementConverter_Generic); - return (JsonConverter)thisFieldInfo.GetValue(nullableConverter)!; - } - - public static void GetEnumConverterConfig(JsonConverter enumConverter, out JsonNamingPolicy? namingPolicy, out bool allowString) - { - // Uses reflection to access configuration encapsulated by an enum converter. - if (_enumConverter_Options_Generic is null) - { - FieldInfo? genericFieldInfo = Type - .GetType("System.Text.Json.Serialization.Converters.EnumConverter`1, System.Text.Json")! - .GetField("_converterOptions", AllInstance); - - _enumConverter_Options_Generic = Throw.IfNull(genericFieldInfo); - } - - if (_enumConverter_NamingPolicy_Generic is null) - { - FieldInfo? genericFieldInfo = Type - .GetType("System.Text.Json.Serialization.Converters.EnumConverter`1, System.Text.Json")! - .GetField("_namingPolicy", AllInstance); - - _enumConverter_NamingPolicy_Generic = Throw.IfNull(genericFieldInfo); - } - - const int EnumConverterOptionsAllowStrings = 1; - Type converterType = enumConverter.GetType(); - var converterOptionsField = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_enumConverter_Options_Generic); - var namingPolicyField = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_enumConverter_NamingPolicy_Generic); - - namingPolicy = (JsonNamingPolicy?)namingPolicyField.GetValue(enumConverter); - int converterOptions = (int)converterOptionsField.GetValue(enumConverter)!; - allowString = (converterOptions & EnumConverterOptionsAllowStrings) != 0; - } - - // The .NET 8 source generator doesn't populate attribute providers for properties - // cf. https://github.com/dotnet/runtime/issues/100095 - // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property - // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 - public static ICustomAttributeProvider? ResolveAttributeProvider( - [DynamicallyAccessedMembers( - DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties | - DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] - Type? declaringType, - JsonPropertyInfo? propertyInfo) - { - if (declaringType is null || propertyInfo is null) - { - return null; - } - - if (propertyInfo.AttributeProvider is { } provider) - { - return provider; - } - - string? memberName = ReflectionHelpers.GetMemberName(propertyInfo); - if (memberName is not null) - { - return (MemberInfo?)declaringType.GetProperty(memberName, AllInstance) ?? - declaringType.GetField(memberName, AllInstance); - } - - return null; - } - - // Resolves the parameters of the deserialization constructor for a type, if they exist. - public static Func? ResolveJsonConstructorParameterMapper( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] - Type type, - JsonTypeInfo typeInfo) - { - Debug.Assert(type == typeInfo.Type, "The declaring type must match the typeInfo type."); - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object, "Should only be passed object JSON kinds."); - - if (typeInfo.Properties.Count > 0 && - typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used - TryGetDeserializationConstructor(type, useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) - { - ParameterInfo[]? parameters = ctor?.GetParameters(); - if (parameters?.Length > 0) - { - Dictionary dict = new(parameters.Length); - foreach (ParameterInfo parameter in parameters) - { - if (parameter.Name is not null) - { - // We don't care about null parameter names or conflicts since they - // would have already been rejected by JsonTypeInfo exporterOptions. - dict[new(parameter.Name, parameter.ParameterType)] = parameter; - } - } - - return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; - } - } - - return null; - } - - // Resolves the nullable reference type annotations for a property or field, - // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. - public static NullabilityInfo GetMemberNullability(NullabilityInfoContext context, MemberInfo memberInfo) - { - Debug.Assert(memberInfo is PropertyInfo or FieldInfo, "Member must be property or field."); - return memberInfo is PropertyInfo prop - ? context.Create(prop) - : context.Create((FieldInfo)memberInfo); - } - - public static NullabilityState GetParameterNullability(NullabilityInfoContext context, ParameterInfo parameterInfo) - { -#if NET8_0 - // Workaround for https://github.com/dotnet/runtime/issues/92487 - // The fix has been incorporated into .NET 9 (and the polyfilled implementations in netfx). - // Should be removed once .NET 8 support is dropped. - if (GetGenericParameterDefinition(parameterInfo) is { ParameterType: { IsGenericParameter: true } typeParam }) - { - // Step 1. Look for nullable annotations on the type parameter. - if (GetNullableFlags(typeParam) is byte[] flags) - { - return TranslateByte(flags[0]); - } - - // Step 2. Look for nullable annotations on the generic method declaration. - if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) - { - return TranslateByte(flag); - } - - // Step 3. Look for nullable annotations on the generic method declaration. - if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) - { - return TranslateByte(flag2); - } - - // Default to nullable. - return NullabilityState.Nullable; - - static byte[]? GetNullableFlags(MemberInfo member) - { - foreach (CustomAttributeData attr in member.GetCustomAttributesData()) - { - Type attrType = attr.AttributeType; - if (attrType.Name == "NullableAttribute" && attrType.Namespace == "System.Runtime.CompilerServices") - { - foreach (CustomAttributeTypedArgument ctorArg in attr.ConstructorArguments) - { - switch (ctorArg.Value) - { - case byte flag: - return [flag]; - case byte[] flags: - return flags; - } - } - } - } - - return null; - } - - static byte? GetNullableContextFlag(MemberInfo member) - { - foreach (CustomAttributeData attr in member.GetCustomAttributesData()) - { - Type attrType = attr.AttributeType; - if (attrType.Name == "NullableContextAttribute" && attrType.Namespace == "System.Runtime.CompilerServices") - { - foreach (CustomAttributeTypedArgument ctorArg in attr.ConstructorArguments) - { - if (ctorArg.Value is byte flag) - { - return flag; - } - } - } - } - - return null; - } - -#pragma warning disable S109 // Magic numbers should not be used - static NullabilityState TranslateByte(byte b) => b switch - { - 1 => NullabilityState.NotNull, - 2 => NullabilityState.Nullable, - _ => NullabilityState.Unknown - }; -#pragma warning restore S109 // Magic numbers should not be used - } - - static ParameterInfo GetGenericParameterDefinition(ParameterInfo parameter) - { - if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } - or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) - { - var genericMethod = (MethodBase)GetGenericMemberDefinition(parameter.Member); - return genericMethod.GetParameters()[parameter.Position]; - } - - return parameter; - } - - static MemberInfo GetGenericMemberDefinition(MemberInfo member) - { - if (member is Type type) - { - return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; - } - - if (member.DeclaringType?.IsConstructedGenericType is true) - { - return member.DeclaringType.GetGenericTypeDefinition().GetMemberWithSameMetadataDefinitionAs(member); - } - - if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) - { - return method.GetGenericMethodDefinition(); - } - - return member; - } -#endif - return context.Create(parameterInfo).WriteState; - } - - // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 - public static object? GetNormalizedDefaultValue(ParameterInfo parameterInfo) - { - Type parameterType = parameterInfo.ParameterType; - object? defaultValue = parameterInfo.DefaultValue; - - if (defaultValue is null) - { - return null; - } - - // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. - if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) - { - return null; - } - - // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly - // cf. https://github.com/dotnet/runtime/issues/68647 - if (parameterType.IsEnum) - { - return Enum.ToObject(parameterType, defaultValue); - } - - if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) - { - return Enum.ToObject(underlyingType, defaultValue); - } - - return defaultValue; - } - - // Resolves the deserialization constructor for a type using logic copied from - // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 - private static bool TryGetDeserializationConstructor( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] - Type type, - bool useDefaultCtorInAnnotatedStructs, - out ConstructorInfo? deserializationCtor) - { - ConstructorInfo? ctorWithAttribute = null; - ConstructorInfo? publicParameterlessCtor = null; - ConstructorInfo? lonePublicCtor = null; - - ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); - - if (constructors.Length == 1) - { - lonePublicCtor = constructors[0]; - } - - foreach (ConstructorInfo constructor in constructors) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - else if (constructor.GetParameters().Length == 0) - { - publicParameterlessCtor = constructor; - } - } - - // Search for non-public ctors with [JsonConstructor]. - foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - } - - // Structs will use default constructor if attribute isn't used. - if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) - { - deserializationCtor = null; - return true; - } - - deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; - return true; - - static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => - constructorInfo.GetCustomAttribute() != null; - } - - // Parameter to property matching semantics as declared in - // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 - private readonly struct ParameterLookupKey : IEquatable - { - public ParameterLookupKey(string name, Type type) - { - Name = name; - Type = type; - } - - public string Name { get; } - public Type Type { get; } - - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); - public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); - public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); - } - } - -#if !NET - private static MemberInfo GetMemberWithSameMetadataDefinitionAs(this Type specializedType, MemberInfo member) - { - const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; - return specializedType.GetMember(member.Name, member.MemberType, All).First(m => m.MetadataToken == member.MetadataToken); - } -#endif -} -#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs deleted file mode 100644 index d651ce6a727..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs +++ /dev/null @@ -1,805 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Reflection; -#if NET -using System.Runtime.InteropServices; -#endif -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable LA0002 // Use 'Microsoft.Shared.Text.NumericExtensions.ToInvariantString' for improved performance -#pragma warning disable S107 // Methods should not have too many parameters -#pragma warning disable S1121 // Assignments should not be made from within sub-expressions - -namespace System.Text.Json.Schema; - -/// -/// Maps .NET types to JSON schema objects using contract metadata from instances. -/// -#if !SHARED_PROJECT -[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -#endif -internal static partial class JsonSchemaExporter -{ - // Polyfill implementation of JsonSchemaExporter for System.Text.Json version 8.0.0. - // Uses private reflection to access metadata not available with the older APIs of STJ. - - private const string RequiresUnreferencedCodeMessage = - "Uses private reflection on System.Text.Json components to access converter metadata. " + - "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."; - - /// - /// Generates a JSON schema corresponding to the contract metadata of the specified type. - /// - /// The options instance from which to resolve the contract metadata. - /// The root type for which to generate the JSON schema. - /// The exporterOptions object controlling the schema generation. - /// A new instance defining the JSON schema for . - /// One of the specified parameters is . - /// The parameter contains unsupported exporterOptions. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - public static JsonNode GetJsonSchemaAsNode(this JsonSerializerOptions options, Type type, JsonSchemaExporterOptions? exporterOptions = null) - { - _ = Throw.IfNull(options); - _ = Throw.IfNull(type); - ValidateOptions(options); - - exporterOptions ??= JsonSchemaExporterOptions.Default; - JsonTypeInfo typeInfo = options.GetTypeInfo(type); - return MapRootTypeJsonSchema(typeInfo, exporterOptions); - } - - /// - /// Generates a JSON schema corresponding to the specified contract metadata. - /// - /// The contract metadata for which to generate the schema. - /// The exporterOptions object controlling the schema generation. - /// A new instance defining the JSON schema for . - /// One of the specified parameters is . - /// The parameter contains unsupported exporterOptions. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - public static JsonNode GetJsonSchemaAsNode(this JsonTypeInfo typeInfo, JsonSchemaExporterOptions? exporterOptions = null) - { - _ = Throw.IfNull(typeInfo); - ValidateOptions(typeInfo.Options); - - exporterOptions ??= JsonSchemaExporterOptions.Default; - return MapRootTypeJsonSchema(typeInfo, exporterOptions); - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static JsonNode MapRootTypeJsonSchema(JsonTypeInfo typeInfo, JsonSchemaExporterOptions exporterOptions) - { - GenerationState state = new(exporterOptions, typeInfo.Options); - JsonSchema schema = MapJsonSchemaCore(ref state, typeInfo); - return schema.ToJsonNode(exporterOptions); - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static JsonSchema MapJsonSchemaCore( - ref GenerationState state, - JsonTypeInfo typeInfo, - Type? parentType = null, - JsonPropertyInfo? propertyInfo = null, - ICustomAttributeProvider? propertyAttributeProvider = null, - ParameterInfo? parameterInfo = null, - bool isNonNullableType = false, - JsonConverter? customConverter = null, - JsonNumberHandling? customNumberHandling = null, - JsonTypeInfo? parentPolymorphicTypeInfo = null, - bool parentPolymorphicTypeContainsTypesWithoutDiscriminator = false, - bool parentPolymorphicTypeIsNonNullable = false, - KeyValuePair? typeDiscriminator = null, - bool cacheResult = true) - { - Debug.Assert(typeInfo.IsReadOnly, "The specified contract must have been made read-only."); - - JsonSchemaExporterContext exporterContext = state.CreateContext(typeInfo, parentPolymorphicTypeInfo, parentType, propertyInfo, parameterInfo, propertyAttributeProvider); - - if (cacheResult && typeInfo.Kind is not JsonTypeInfoKind.None && - state.TryGetExistingJsonPointer(exporterContext, out string? existingJsonPointer)) - { - // The schema context has already been generated in the schema document, return a reference to it. - return CompleteSchema(ref state, new JsonSchema { Ref = existingJsonPointer }); - } - - JsonSchema schema; - JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter; - JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Options.NumberHandling; - - if (!ReflectionHelpers.IsBuiltInConverter(effectiveConverter)) - { - // Return a `true` schema for types with user-defined converters. - return CompleteSchema(ref state, JsonSchema.CreateTrueSchema()); - } - - if (parentPolymorphicTypeInfo is null && typeInfo.PolymorphismOptions is { DerivedTypes.Count: > 0 } polyOptions) - { - // This is the base type of a polymorphic type hierarchy. The schema for this type - // will include an "anyOf" property with the schemas for all derived types. - - string typeDiscriminatorKey = polyOptions.TypeDiscriminatorPropertyName; - List derivedTypes = polyOptions.DerivedTypes.ToList(); - - if (!typeInfo.Type.IsAbstract && !derivedTypes.Any(derived => derived.DerivedType == typeInfo.Type)) - { - // For non-abstract base types that haven't been explicitly configured, - // add a trivial schema to the derived types since we should support it. - derivedTypes.Add(new JsonDerivedType(typeInfo.Type)); - } - - bool containsTypesWithoutDiscriminator = derivedTypes.Exists(static derivedTypes => derivedTypes.TypeDiscriminator is null); - JsonSchemaType schemaType = JsonSchemaType.Any; - List? anyOf = new(derivedTypes.Count); - - state.PushSchemaNode(JsonSchemaConstants.AnyOfPropertyName); - - foreach (JsonDerivedType derivedType in derivedTypes) - { - Debug.Assert(derivedType.TypeDiscriminator is null or int or string, "Type discriminator does not have the expected type."); - - KeyValuePair? derivedTypeDiscriminator = null; - if (derivedType.TypeDiscriminator is { } discriminatorValue) - { - JsonNode discriminatorNode = discriminatorValue switch - { - string stringId => (JsonNode)stringId, - _ => (JsonNode)(int)discriminatorValue, - }; - - JsonSchema discriminatorSchema = new() { Constant = discriminatorNode }; - derivedTypeDiscriminator = new(typeDiscriminatorKey, discriminatorSchema); - } - - JsonTypeInfo derivedTypeInfo = typeInfo.Options.GetTypeInfo(derivedType.DerivedType); - - state.PushSchemaNode(anyOf.Count.ToString(CultureInfo.InvariantCulture)); - JsonSchema derivedSchema = MapJsonSchemaCore( - ref state, - derivedTypeInfo, - parentPolymorphicTypeInfo: typeInfo, - typeDiscriminator: derivedTypeDiscriminator, - parentPolymorphicTypeContainsTypesWithoutDiscriminator: containsTypesWithoutDiscriminator, - parentPolymorphicTypeIsNonNullable: isNonNullableType, - cacheResult: false); - - state.PopSchemaNode(); - - // Determine if all derived schemas have the same type. - if (anyOf.Count == 0) - { - schemaType = derivedSchema.Type; - } - else if (schemaType != derivedSchema.Type) - { - schemaType = JsonSchemaType.Any; - } - - anyOf.Add(derivedSchema); - } - - state.PopSchemaNode(); - - if (schemaType is not JsonSchemaType.Any) - { - // If all derived types have the same schema type, we can simplify the schema - // by moving the type keyword to the base schema and removing it from the derived schemas. - foreach (JsonSchema derivedSchema in anyOf) - { - derivedSchema.Type = JsonSchemaType.Any; - - if (derivedSchema.KeywordCount == 0) - { - // if removing the type results in an empty schema, - // remove the anyOf array entirely since it's always true. - anyOf = null; - break; - } - } - } - - schema = new() - { - Type = schemaType, - AnyOf = anyOf, - - // If all derived types have a discriminator, we can require it in the base schema. - Required = containsTypesWithoutDiscriminator ? null : new() { typeDiscriminatorKey }, - }; - - return CompleteSchema(ref state, schema); - } - - if (Nullable.GetUnderlyingType(typeInfo.Type) is Type nullableElementType) - { - JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(nullableElementType); - customConverter = ExtractCustomNullableConverter(customConverter); - schema = MapJsonSchemaCore(ref state, elementTypeInfo, customConverter: customConverter, cacheResult: false); - - if (schema.Enum != null) - { - Debug.Assert(elementTypeInfo.Type.IsEnum, "The enum keyword should only be populated by schemas for enum types."); - schema.Enum.Add(null); // Append null to the enum array. - } - - return CompleteSchema(ref state, schema); - } - - switch (typeInfo.Kind) - { - case JsonTypeInfoKind.Object: - List>? properties = null; - List? required = null; - JsonSchema? additionalProperties = null; - - JsonUnmappedMemberHandling effectiveUnmappedMemberHandling = typeInfo.UnmappedMemberHandling ?? typeInfo.Options.UnmappedMemberHandling; - if (effectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) - { - // Disallow unspecified properties. - additionalProperties = JsonSchema.CreateFalseSchema(); - } - - if (typeDiscriminator is { } typeDiscriminatorPair) - { - (properties = new()).Add(typeDiscriminatorPair); - if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) - { - // Require the discriminator here since it's not common to all derived types. - (required = new()).Add(typeDiscriminatorPair.Key); - } - } - - Func? parameterInfoMapper = - ReflectionHelpers.ResolveJsonConstructorParameterMapper(typeInfo.Type, typeInfo); - - state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); - foreach (JsonPropertyInfo property in typeInfo.Properties) - { - if (property is { Get: null, Set: null } or { IsExtensionData: true }) - { - continue; // Skip JsonIgnored properties and extension data - } - - JsonNumberHandling? propertyNumberHandling = property.NumberHandling ?? effectiveNumberHandling; - JsonTypeInfo propertyTypeInfo = typeInfo.Options.GetTypeInfo(property.PropertyType); - - // Resolve the attribute provider for the property. - ICustomAttributeProvider? attributeProvider = ReflectionHelpers.ResolveAttributeProvider(typeInfo.Type, property); - - // Declare the property as nullable if either getter or setter are nullable. - bool isNonNullableProperty = false; - if (attributeProvider is MemberInfo memberInfo) - { - NullabilityInfo nullabilityInfo = ReflectionHelpers.GetMemberNullability(state.NullabilityInfoContext, memberInfo); - isNonNullableProperty = - (property.Get is null || nullabilityInfo.ReadState is NullabilityState.NotNull) && - (property.Set is null || nullabilityInfo.WriteState is NullabilityState.NotNull); - } - - bool isRequired = property.IsRequired; - bool hasDefaultValue = false; - JsonNode? defaultValue = null; - - ParameterInfo? associatedParameter = parameterInfoMapper?.Invoke(property); - if (associatedParameter != null) - { - ResolveParameterInfo( - associatedParameter, - propertyTypeInfo, - state.NullabilityInfoContext, - out hasDefaultValue, - out defaultValue, - out bool isNonNullableParameter, - ref isRequired); - - isNonNullableProperty &= isNonNullableParameter; - } - - state.PushSchemaNode(property.Name); - JsonSchema propertySchema = MapJsonSchemaCore( - ref state, - propertyTypeInfo, - parentType: typeInfo.Type, - propertyInfo: property, - parameterInfo: associatedParameter, - propertyAttributeProvider: attributeProvider, - isNonNullableType: isNonNullableProperty, - customConverter: property.CustomConverter, - customNumberHandling: propertyNumberHandling); - - state.PopSchemaNode(); - - if (hasDefaultValue) - { - JsonSchema.EnsureMutable(ref propertySchema); - propertySchema.DefaultValue = defaultValue; - propertySchema.HasDefaultValue = true; - } - - (properties ??= new()).Add(new(property.Name, propertySchema)); - - if (isRequired) - { - (required ??= new()).Add(property.Name); - } - } - - state.PopSchemaNode(); - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Object, - Properties = properties, - Required = required, - AdditionalProperties = additionalProperties, - }); - - case JsonTypeInfoKind.Enumerable: - Type elementType = ReflectionHelpers.GetElementType(typeInfo); - JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementType); - - if (typeDiscriminator is null) - { - state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); - JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); - state.PopSchemaNode(); - - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Array, - Items = items.IsTrue ? null : items, - }); - } - else - { - // Polymorphic enumerable types are represented using a wrapping object: - // { "$type" : "discriminator", "$values" : [element1, element2, ...] } - // Which corresponds to the schema - // { "properties" : { "$type" : { "const" : "discriminator" }, "$values" : { "type" : "array", "items" : { ... } } } } - const string ValuesKeyword = "$values"; - - state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); - state.PushSchemaNode(ValuesKeyword); - state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); - - JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); - - state.PopSchemaNode(); - state.PopSchemaNode(); - state.PopSchemaNode(); - - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Object, - Properties = new() - { - typeDiscriminator.Value, - new(ValuesKeyword, - new JsonSchema - { - Type = JsonSchemaType.Array, - Items = items.IsTrue ? null : items, - }), - }, - Required = parentPolymorphicTypeContainsTypesWithoutDiscriminator ? new() { typeDiscriminator.Value.Key } : null, - }); - } - - case JsonTypeInfoKind.Dictionary: - Type valueType = ReflectionHelpers.GetElementType(typeInfo); - JsonTypeInfo valueTypeInfo = typeInfo.Options.GetTypeInfo(valueType); - - List>? dictProps = null; - List? dictRequired = null; - - if (typeDiscriminator is { } dictDiscriminator) - { - dictProps = new() { dictDiscriminator }; - if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) - { - // Require the discriminator here since it's not common to all derived types. - dictRequired = new() { dictDiscriminator.Key }; - } - } - - state.PushSchemaNode(JsonSchemaConstants.AdditionalPropertiesPropertyName); - JsonSchema valueSchema = MapJsonSchemaCore(ref state, valueTypeInfo, customNumberHandling: effectiveNumberHandling); - state.PopSchemaNode(); - - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Object, - Properties = dictProps, - Required = dictRequired, - AdditionalProperties = valueSchema.IsTrue ? null : valueSchema, - }); - - default: - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.None, "The default case should handle unrecognize type kinds."); - - if (_simpleTypeSchemaFactories.TryGetValue(typeInfo.Type, out Func? simpleTypeSchemaFactory)) - { - schema = simpleTypeSchemaFactory(effectiveNumberHandling); - } - else if (typeInfo.Type.IsEnum) - { - schema = GetEnumConverterSchema(typeInfo, effectiveConverter); - } - else - { - schema = JsonSchema.CreateTrueSchema(); - } - - return CompleteSchema(ref state, schema); - } - - JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) - { - if (schema.Ref is null) - { - if (IsNullableSchema(ref state)) - { - schema.MakeNullable(); - } - - bool IsNullableSchema(ref GenerationState state) - { - // A schema is marked as nullable if either: - // 1. We have a schema for a property where either the getter or setter are marked as nullable. - // 2. We have a schema for a Nullable type. - // 3. We have a schema for a reference type, unless we're explicitly treating null-oblivious types as non-nullable. - - if (propertyInfo != null || parameterInfo != null) - { - return !isNonNullableType; - } - - if (Nullable.GetUnderlyingType(typeInfo.Type) is not null) - { - return true; - } - - return !typeInfo.Type.IsValueType && - !parentPolymorphicTypeIsNonNullable && - !state.ExporterOptions.TreatNullObliviousAsNonNullable; - } - } - - if (state.ExporterOptions.TransformSchemaNode != null) - { - // Prime the schema for invocation by the JsonNode transformer. - schema.GenerationContext = exporterContext; - } - - return schema; - } - } - - private readonly ref struct GenerationState - { - private const int DefaultMaxDepth = 64; - private readonly List _currentPath = new(); - private readonly Dictionary<(JsonTypeInfo, JsonPropertyInfo?), string[]> _generated = new(); - private readonly int _maxDepth; - - public GenerationState(JsonSchemaExporterOptions exporterOptions, JsonSerializerOptions options, NullabilityInfoContext? nullabilityInfoContext = null) - { - ExporterOptions = exporterOptions; - NullabilityInfoContext = nullabilityInfoContext ?? new(); - _maxDepth = options.MaxDepth is 0 ? DefaultMaxDepth : options.MaxDepth; - } - - public JsonSchemaExporterOptions ExporterOptions { get; } - public NullabilityInfoContext NullabilityInfoContext { get; } - public int CurrentDepth => _currentPath.Count; - - public void PushSchemaNode(string nodeId) - { - if (CurrentDepth == _maxDepth) - { - ThrowHelpers.ThrowInvalidOperationException_MaxDepthReached(); - } - - _currentPath.Add(nodeId); - } - - public void PopSchemaNode() - { - _currentPath.RemoveAt(_currentPath.Count - 1); - } - - /// - /// Registers the current schema node generation context; if it has already been generated return a JSON pointer to its location. - /// - public bool TryGetExistingJsonPointer(in JsonSchemaExporterContext context, [NotNullWhen(true)] out string? existingJsonPointer) - { - (JsonTypeInfo, JsonPropertyInfo?) key = (context.TypeInfo, context.PropertyInfo); -#if NET - ref string[]? pathToSchema = ref CollectionsMarshal.GetValueRefOrAddDefault(_generated, key, out bool exists); -#else - bool exists = _generated.TryGetValue(key, out string[]? pathToSchema); -#endif - if (exists) - { - existingJsonPointer = FormatJsonPointer(pathToSchema); - return true; - } -#if NET - pathToSchema = context._path; -#else - _generated[key] = context._path; -#endif - existingJsonPointer = null; - return false; - } - - public JsonSchemaExporterContext CreateContext( - JsonTypeInfo typeInfo, - JsonTypeInfo? baseTypeInfo, - Type? declaringType, - JsonPropertyInfo? propertyInfo, - ParameterInfo? parameterInfo, - ICustomAttributeProvider? propertyAttributeProvider) - { - return new JsonSchemaExporterContext(typeInfo, baseTypeInfo, declaringType, propertyInfo, parameterInfo, propertyAttributeProvider, _currentPath.ToArray()); - } - - private static string FormatJsonPointer(ReadOnlySpan path) - { - if (path.IsEmpty) - { - return "#"; - } - - StringBuilder sb = new(); - _ = sb.Append('#'); - - for (int i = 0; i < path.Length; i++) - { - string segment = path[i]; - if (segment.AsSpan().IndexOfAny('~', '/') != -1) - { -#pragma warning disable CA1307 // Specify StringComparison for clarity - segment = segment.Replace("~", "~0").Replace("/", "~1"); -#pragma warning restore CA1307 - } - - _ = sb.Append('/'); - _ = sb.Append(segment); - } - - return sb.ToString(); - } - } - - private static readonly Dictionary> _simpleTypeSchemaFactories = new() - { - [typeof(object)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(bool)] = _ => new JsonSchema { Type = JsonSchemaType.Boolean }, - [typeof(byte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(ushort)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(uint)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(ulong)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(sbyte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(short)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(int)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(long)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(float)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), - [typeof(double)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), - [typeof(decimal)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling), -#if NET6_0_OR_GREATER - [typeof(Half)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), -#endif -#if NET7_0_OR_GREATER - [typeof(UInt128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(Int128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), -#endif - [typeof(char)] = _ => new JsonSchema { Type = JsonSchemaType.String, MinLength = 1, MaxLength = 1 }, - [typeof(string)] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(byte[])] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(Memory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(ReadOnlyMemory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(DateTime)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, - [typeof(DateTimeOffset)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, - [typeof(TimeSpan)] = _ => new JsonSchema - { - Comment = "Represents a System.TimeSpan value.", - Type = JsonSchemaType.String, - Pattern = @"^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$", - }, - -#if NET6_0_OR_GREATER - [typeof(DateOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date" }, - [typeof(TimeOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "time" }, -#endif - [typeof(Guid)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uuid" }, - [typeof(Uri)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uri" }, - [typeof(Version)] = _ => new JsonSchema - { - Comment = "Represents a version string.", - Type = JsonSchemaType.String, - Pattern = @"^\d+(\.\d+){1,3}$", - }, - - [typeof(JsonDocument)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonElement)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonNode)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonValue)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonObject)] = _ => new JsonSchema { Type = JsonSchemaType.Object }, - [typeof(JsonArray)] = _ => new JsonSchema { Type = JsonSchemaType.Array }, - }; - - // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/JsonPrimitiveConverter.cs#L36-L69 - private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, JsonNumberHandling numberHandling, bool isIeeeFloatingPoint = false) - { - Debug.Assert(schemaType is JsonSchemaType.Integer or JsonSchemaType.Number, "schema type must be number or integer"); - Debug.Assert(!isIeeeFloatingPoint || schemaType is JsonSchemaType.Number, "If specifying IEEE the schema type must be number"); - - string? pattern = null; - - if ((numberHandling & (JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)) != 0) - { - if (schemaType is JsonSchemaType.Integer) - { - pattern = @"^-?(?:0|[1-9]\d*)$"; - } - else if (isIeeeFloatingPoint) - { - pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$"; - } - else - { - pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; - } - - schemaType |= JsonSchemaType.String; - } - - if (isIeeeFloatingPoint && (numberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0) - { - return new JsonSchema - { - AnyOf = new() - { - new JsonSchema { Type = schemaType, Pattern = pattern }, - new JsonSchema { Enum = new() { (JsonNode)"NaN", (JsonNode)"Infinity", (JsonNode)"-Infinity" } }, - }, - }; - } - - return new JsonSchema { Type = schemaType, Pattern = pattern }; - } - - private static JsonConverter? ExtractCustomNullableConverter(JsonConverter? converter) - { - Debug.Assert(converter is null || ReflectionHelpers.IsBuiltInConverter(converter), "If specified the converter must be built-in."); - - if (converter is null) - { - return null; - } - - return ReflectionHelpers.GetElementConverter(converter); - } - - private static void ValidateOptions(JsonSerializerOptions options) - { - if (options.ReferenceHandler == ReferenceHandler.Preserve) - { - ThrowHelpers.ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported(); - } - - options.MakeReadOnly(); - } - - private static void ResolveParameterInfo( - ParameterInfo parameter, - JsonTypeInfo parameterTypeInfo, - NullabilityInfoContext nullabilityInfoContext, - out bool hasDefaultValue, - out JsonNode? defaultValue, - out bool isNonNullable, - ref bool isRequired) - { - Debug.Assert(parameterTypeInfo.Type == parameter.ParameterType, "The typeInfo type must match the ParameterInfo type."); - - // Incorporate the nullability information from the parameter. - isNonNullable = ReflectionHelpers.GetParameterNullability(nullabilityInfoContext, parameter) is NullabilityState.NotNull; - - if (parameter.HasDefaultValue) - { - // Append the default value to the description. - object? defaultVal = ReflectionHelpers.GetNormalizedDefaultValue(parameter); - defaultValue = JsonSerializer.SerializeToNode(defaultVal, parameterTypeInfo); - hasDefaultValue = true; - } - else - { - // Parameter is not optional, mark as required. - isRequired = true; - defaultValue = null; - hasDefaultValue = false; - } - } - - // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs#L498-L521 - private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConverter converter) - { - Debug.Assert(typeInfo.Type.IsEnum && ReflectionHelpers.IsBuiltInConverter(converter), "must be using a built-in enum converter."); - - if (converter is JsonConverterFactory factory) - { - converter = factory.CreateConverter(typeInfo.Type, typeInfo.Options)!; - } - - ReflectionHelpers.GetEnumConverterConfig(converter, out JsonNamingPolicy? namingPolicy, out bool allowString); - - if (allowString) - { - // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings - // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity. - - if (typeInfo.Type.GetCustomAttribute() is not null) - { - // Do not report enum values in case of flags. - return new() { Type = JsonSchemaType.String }; - } - - JsonArray enumValues = new(); - foreach (string name in Enum.GetNames(typeInfo.Type)) - { - // This does not account for custom names specified via the new - // JsonStringEnumMemberNameAttribute introduced in .NET 9. - string effectiveName = namingPolicy?.ConvertName(name) ?? name; - enumValues.Add((JsonNode)effectiveName); - } - - return new() { Enum = enumValues }; - } - - return new() { Type = JsonSchemaType.Integer }; - } - - private static class JsonSchemaConstants - { - public const string SchemaPropertyName = "$schema"; - public const string RefPropertyName = "$ref"; - public const string CommentPropertyName = "$comment"; - public const string TitlePropertyName = "title"; - public const string DescriptionPropertyName = "description"; - public const string TypePropertyName = "type"; - public const string FormatPropertyName = "format"; - public const string PatternPropertyName = "pattern"; - public const string PropertiesPropertyName = "properties"; - public const string RequiredPropertyName = "required"; - public const string ItemsPropertyName = "items"; - public const string AdditionalPropertiesPropertyName = "additionalProperties"; - public const string EnumPropertyName = "enum"; - public const string NotPropertyName = "not"; - public const string AnyOfPropertyName = "anyOf"; - public const string ConstPropertyName = "const"; - public const string DefaultPropertyName = "default"; - public const string MinLengthPropertyName = "minLength"; - public const string MaxLengthPropertyName = "maxLength"; - } - - private static class ThrowHelpers - { - [DoesNotReturn] - public static void ThrowInvalidOperationException_MaxDepthReached() => - throw new InvalidOperationException("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting."); - - [DoesNotReturn] - public static void ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported() => - throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs deleted file mode 100644 index 0be37988f8c..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Reflection; -using System.Text.Json.Serialization.Metadata; - -namespace System.Text.Json.Schema; - -/// -/// Defines the context in which a JSON schema within a type graph is being generated. -/// -#if !SHARED_PROJECT -[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -#endif -internal readonly struct JsonSchemaExporterContext -{ -#pragma warning disable IDE1006 // Naming Styles - internal readonly string[] _path; -#pragma warning restore IDE1006 // Naming Styles - - internal JsonSchemaExporterContext( - JsonTypeInfo typeInfo, - JsonTypeInfo? baseTypeInfo, - Type? declaringType, - JsonPropertyInfo? propertyInfo, - ParameterInfo? parameterInfo, - ICustomAttributeProvider? propertyAttributeProvider, - string[] path) - { - TypeInfo = typeInfo; - DeclaringType = declaringType; - BaseTypeInfo = baseTypeInfo; - PropertyInfo = propertyInfo; - ParameterInfo = parameterInfo; - PropertyAttributeProvider = propertyAttributeProvider; - _path = path; - } - - /// - /// Gets the path to the schema document currently being generated. - /// - public ReadOnlySpan Path => _path; - - /// - /// Gets the for the type being processed. - /// - public JsonTypeInfo TypeInfo { get; } - - /// - /// Gets the declaring type of the property or parameter being processed. - /// - public Type? DeclaringType { get; } - - /// - /// Gets the type info for the polymorphic base type if generated as a derived type. - /// - public JsonTypeInfo? BaseTypeInfo { get; } - - /// - /// Gets the if the schema is being generated for a property. - /// - public JsonPropertyInfo? PropertyInfo { get; } - - /// - /// Gets the if a constructor parameter - /// has been associated with the accompanying . - /// - public ParameterInfo? ParameterInfo { get; } - - /// - /// Gets the corresponding to the property or field being processed. - /// - public ICustomAttributeProvider? PropertyAttributeProvider { get; } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs deleted file mode 100644 index c915df0c53a..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Text.Json.Nodes; - -namespace System.Text.Json.Schema; - -/// -/// Controls the behavior of the class. -/// -#if !SHARED_PROJECT -[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -#endif -internal sealed class JsonSchemaExporterOptions -{ - /// - /// Gets the default configuration object used by . - /// - public static JsonSchemaExporterOptions Default { get; } = new(); - - /// - /// Gets a value indicating whether non-nullable schemas should be generated for null oblivious reference types. - /// - /// - /// Defaults to . Due to restrictions in the run-time representation of nullable reference types - /// most occurrences are null oblivious and are treated as nullable by the serializer. A notable exception to that rule - /// are nullability annotations of field, property and constructor parameters which are represented in the contract metadata. - /// - public bool TreatNullObliviousAsNonNullable { get; init; } - - /// - /// Gets a callback that is invoked for every schema that is generated within the type graph. - /// - public Func? TransformSchemaNode { get; init; } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs deleted file mode 100644 index bd9b132cd0f..00000000000 --- a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; - -#pragma warning disable SA1623 // Property summary documentation should match accessors - -namespace System.Reflection -{ - /// - /// A class that represents nullability info. - /// - [ExcludeFromCodeCoverage] - internal sealed class NullabilityInfo - { - internal NullabilityInfo(Type type, NullabilityState readState, NullabilityState writeState, - NullabilityInfo? elementType, NullabilityInfo[] typeArguments) - { - Type = type; - ReadState = readState; - WriteState = writeState; - ElementType = elementType; - GenericTypeArguments = typeArguments; - } - - /// - /// The of the member or generic parameter - /// to which this NullabilityInfo belongs. - /// - public Type Type { get; } - - /// - /// The nullability read state of the member. - /// - public NullabilityState ReadState { get; internal set; } - - /// - /// The nullability write state of the member. - /// - public NullabilityState WriteState { get; internal set; } - - /// - /// If the member type is an array, gives the of the elements of the array, null otherwise. - /// - public NullabilityInfo? ElementType { get; } - - /// - /// If the member type is a generic type, gives the array of for each type parameter. - /// - public NullabilityInfo[] GenericTypeArguments { get; } - } - - /// - /// An enum that represents nullability state. - /// - internal enum NullabilityState - { - /// - /// Nullability context not enabled (oblivious). - /// - Unknown, - - /// - /// Non nullable value or reference type. - /// - NotNull, - - /// - /// Nullable value or reference type. - /// - Nullable, - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs deleted file mode 100644 index 3edee1b9cb8..00000000000 --- a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs +++ /dev/null @@ -1,661 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; - -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S109 // Magic numbers should not be used -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S4136 // Method overloads should be grouped together -#pragma warning disable SA1202 // Elements should be ordered by access -#pragma warning disable IDE1006 // Naming Styles - -namespace System.Reflection -{ - /// - /// Provides APIs for populating nullability information/context from reflection members: - /// , , and . - /// - [ExcludeFromCodeCoverage] - internal sealed class NullabilityInfoContext - { - private const string CompilerServicesNameSpace = "System.Runtime.CompilerServices"; - private readonly Dictionary _publicOnlyModules = new(); - private readonly Dictionary _context = new(); - - [Flags] - private enum NotAnnotatedStatus - { - None = 0x0, // no restriction, all members annotated - Private = 0x1, // private members not annotated - Internal = 0x2, // internal members not annotated - } - - private NullabilityState? GetNullableContext(MemberInfo? memberInfo) - { - while (memberInfo != null) - { - if (_context.TryGetValue(memberInfo, out NullabilityState state)) - { - return state; - } - - foreach (CustomAttributeData attribute in memberInfo.GetCustomAttributesData()) - { - if (attribute.AttributeType.Name == "NullableContextAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - state = TranslateByte(attribute.ConstructorArguments[0].Value); - _context.Add(memberInfo, state); - return state; - } - } - - memberInfo = memberInfo.DeclaringType; - } - - return null; - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the parameterInfo parameter is null. - /// . - public NullabilityInfo Create(ParameterInfo parameterInfo) - { - IList attributes = parameterInfo.GetCustomAttributesData(); - NullableAttributeStateParser parser = parameterInfo.Member is MethodBase method && IsPrivateOrInternalMethodAndAnnotationDisabled(method) - ? NullableAttributeStateParser.Unknown - : CreateParser(attributes); - NullabilityInfo nullability = GetNullabilityInfo(parameterInfo.Member, parameterInfo.ParameterType, parser); - - if (nullability.ReadState != NullabilityState.Unknown) - { - CheckParameterMetadataType(parameterInfo, nullability); - } - - CheckNullabilityAttributes(nullability, attributes); - return nullability; - } - - private void CheckParameterMetadataType(ParameterInfo parameter, NullabilityInfo nullability) - { - ParameterInfo? metaParameter; - MemberInfo metaMember; - - switch (parameter.Member) - { - case ConstructorInfo ctor: - var metaCtor = (ConstructorInfo)GetMemberMetadataDefinition(ctor); - metaMember = metaCtor; - metaParameter = GetMetaParameter(metaCtor, parameter); - break; - - case MethodInfo method: - MethodInfo metaMethod = GetMethodMetadataDefinition(method); - metaMember = metaMethod; - metaParameter = string.IsNullOrEmpty(parameter.Name) ? metaMethod.ReturnParameter : GetMetaParameter(metaMethod, parameter); - break; - - default: - return; - } - - if (metaParameter != null) - { - CheckGenericParameters(nullability, metaMember, metaParameter.ParameterType, parameter.Member.ReflectedType); - } - } - - private static ParameterInfo? GetMetaParameter(MethodBase metaMethod, ParameterInfo parameter) - { - var parameters = metaMethod.GetParameters(); - for (int i = 0; i < parameters.Length; i++) - { - if (parameter.Position == i && - parameter.Name == parameters[i].Name) - { - return parameters[i]; - } - } - - return null; - } - - private static MethodInfo GetMethodMetadataDefinition(MethodInfo method) - { - if (method.IsGenericMethod && !method.IsGenericMethodDefinition) - { - method = method.GetGenericMethodDefinition(); - } - - return (MethodInfo)GetMemberMetadataDefinition(method); - } - - private static void CheckNullabilityAttributes(NullabilityInfo nullability, IList attributes) - { - var codeAnalysisReadState = NullabilityState.Unknown; - var codeAnalysisWriteState = NullabilityState.Unknown; - - foreach (CustomAttributeData attribute in attributes) - { - if (attribute.AttributeType.Namespace == "System.Diagnostics.CodeAnalysis") - { - if (attribute.AttributeType.Name == "NotNullAttribute") - { - codeAnalysisReadState = NullabilityState.NotNull; - } - else if ((attribute.AttributeType.Name == "MaybeNullAttribute" || - attribute.AttributeType.Name == "MaybeNullWhenAttribute") && - codeAnalysisReadState == NullabilityState.Unknown && - !IsValueTypeOrValueTypeByRef(nullability.Type)) - { - codeAnalysisReadState = NullabilityState.Nullable; - } - else if (attribute.AttributeType.Name == "DisallowNullAttribute") - { - codeAnalysisWriteState = NullabilityState.NotNull; - } - else if (attribute.AttributeType.Name == "AllowNullAttribute" && - codeAnalysisWriteState == NullabilityState.Unknown && - !IsValueTypeOrValueTypeByRef(nullability.Type)) - { - codeAnalysisWriteState = NullabilityState.Nullable; - } - } - } - - if (codeAnalysisReadState != NullabilityState.Unknown) - { - nullability.ReadState = codeAnalysisReadState; - } - - if (codeAnalysisWriteState != NullabilityState.Unknown) - { - nullability.WriteState = codeAnalysisWriteState; - } - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the propertyInfo parameter is null. - /// . - public NullabilityInfo Create(PropertyInfo propertyInfo) - { - MethodInfo? getter = propertyInfo.GetGetMethod(true); - MethodInfo? setter = propertyInfo.GetSetMethod(true); - bool annotationsDisabled = (getter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(getter)) - && (setter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(setter)); - NullableAttributeStateParser parser = annotationsDisabled ? NullableAttributeStateParser.Unknown : CreateParser(propertyInfo.GetCustomAttributesData()); - NullabilityInfo nullability = GetNullabilityInfo(propertyInfo, propertyInfo.PropertyType, parser); - - if (getter != null) - { - CheckNullabilityAttributes(nullability, getter.ReturnParameter.GetCustomAttributesData()); - } - else - { - nullability.ReadState = NullabilityState.Unknown; - } - - if (setter != null) - { - CheckNullabilityAttributes(nullability, setter.GetParameters().Last().GetCustomAttributesData()); - } - else - { - nullability.WriteState = NullabilityState.Unknown; - } - - return nullability; - } - - private bool IsPrivateOrInternalMethodAndAnnotationDisabled(MethodBase method) - { - if ((method.IsPrivate || method.IsFamilyAndAssembly || method.IsAssembly) && - IsPublicOnly(method.IsPrivate, method.IsFamilyAndAssembly, method.IsAssembly, method.Module)) - { - return true; - } - - return false; - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the eventInfo parameter is null. - /// . - public NullabilityInfo Create(EventInfo eventInfo) - { - return GetNullabilityInfo(eventInfo, eventInfo.EventHandlerType!, CreateParser(eventInfo.GetCustomAttributesData())); - } - - /// - /// Populates for the given - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the fieldInfo parameter is null. - /// . - public NullabilityInfo Create(FieldInfo fieldInfo) - { - IList attributes = fieldInfo.GetCustomAttributesData(); - NullableAttributeStateParser parser = IsPrivateOrInternalFieldAndAnnotationDisabled(fieldInfo) ? NullableAttributeStateParser.Unknown : CreateParser(attributes); - NullabilityInfo nullability = GetNullabilityInfo(fieldInfo, fieldInfo.FieldType, parser); - CheckNullabilityAttributes(nullability, attributes); - return nullability; - } - - private bool IsPrivateOrInternalFieldAndAnnotationDisabled(FieldInfo fieldInfo) - { - if ((fieldInfo.IsPrivate || fieldInfo.IsFamilyAndAssembly || fieldInfo.IsAssembly) && - IsPublicOnly(fieldInfo.IsPrivate, fieldInfo.IsFamilyAndAssembly, fieldInfo.IsAssembly, fieldInfo.Module)) - { - return true; - } - - return false; - } - - private bool IsPublicOnly(bool isPrivate, bool isFamilyAndAssembly, bool isAssembly, Module module) - { - if (!_publicOnlyModules.TryGetValue(module, out NotAnnotatedStatus value)) - { - value = PopulateAnnotationInfo(module.GetCustomAttributesData()); - _publicOnlyModules.Add(module, value); - } - - if (value == NotAnnotatedStatus.None) - { - return false; - } - - if (((isPrivate || isFamilyAndAssembly) && value.HasFlag(NotAnnotatedStatus.Private)) || - (isAssembly && value.HasFlag(NotAnnotatedStatus.Internal))) - { - return true; - } - - return false; - } - - private static NotAnnotatedStatus PopulateAnnotationInfo(IList customAttributes) - { - foreach (CustomAttributeData attribute in customAttributes) - { - if (attribute.AttributeType.Name == "NullablePublicOnlyAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - if (attribute.ConstructorArguments[0].Value is bool boolValue && boolValue) - { - return NotAnnotatedStatus.Internal | NotAnnotatedStatus.Private; - } - else - { - return NotAnnotatedStatus.Private; - } - } - } - - return NotAnnotatedStatus.None; - } - - private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser) - { - int index = 0; - NullabilityInfo nullability = GetNullabilityInfo(memberInfo, type, parser, ref index); - - if (nullability.ReadState != NullabilityState.Unknown) - { - TryLoadGenericMetaTypeNullability(memberInfo, nullability); - } - - return nullability; - } - - private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser, ref int index) - { - NullabilityState state = NullabilityState.Unknown; - NullabilityInfo? elementState = null; - NullabilityInfo[] genericArgumentsState = Array.Empty(); - Type underlyingType = type; - - if (underlyingType.IsByRef || underlyingType.IsPointer) - { - underlyingType = underlyingType.GetElementType()!; - } - - if (underlyingType.IsValueType) - { - if (Nullable.GetUnderlyingType(underlyingType) is { } nullableUnderlyingType) - { - underlyingType = nullableUnderlyingType; - state = NullabilityState.Nullable; - } - else - { - state = NullabilityState.NotNull; - } - - if (underlyingType.IsGenericType) - { - ++index; - } - } - else - { - if (!parser.ParseNullableState(index++, ref state) - && GetNullableContext(memberInfo) is { } contextState) - { - state = contextState; - } - - if (underlyingType.IsArray) - { - elementState = GetNullabilityInfo(memberInfo, underlyingType.GetElementType()!, parser, ref index); - } - } - - if (underlyingType.IsGenericType) - { - Type[] genericArguments = underlyingType.GetGenericArguments(); - genericArgumentsState = new NullabilityInfo[genericArguments.Length]; - - for (int i = 0; i < genericArguments.Length; i++) - { - genericArgumentsState[i] = GetNullabilityInfo(memberInfo, genericArguments[i], parser, ref index); - } - } - - return new NullabilityInfo(type, state, state, elementState, genericArgumentsState); - } - - private static NullableAttributeStateParser CreateParser(IList customAttributes) - { - foreach (CustomAttributeData attribute in customAttributes) - { - if (attribute.AttributeType.Name == "NullableAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - return new NullableAttributeStateParser(attribute.ConstructorArguments[0].Value); - } - } - - return new NullableAttributeStateParser(null); - } - - private void TryLoadGenericMetaTypeNullability(MemberInfo memberInfo, NullabilityInfo nullability) - { - MemberInfo? metaMember = GetMemberMetadataDefinition(memberInfo); - Type? metaType = null; - if (metaMember is FieldInfo field) - { - metaType = field.FieldType; - } - else if (metaMember is PropertyInfo property) - { - metaType = GetPropertyMetaType(property); - } - - if (metaType != null) - { - CheckGenericParameters(nullability, metaMember!, metaType, memberInfo.ReflectedType); - } - } - - private static MemberInfo GetMemberMetadataDefinition(MemberInfo member) - { - Type? type = member.DeclaringType; - if ((type != null) && type.IsGenericType && !type.IsGenericTypeDefinition) - { - return NullabilityInfoHelpers.GetMemberWithSameMetadataDefinitionAs(type.GetGenericTypeDefinition(), member); - } - - return member; - } - - private static Type GetPropertyMetaType(PropertyInfo property) - { - if (property.GetGetMethod(true) is MethodInfo method) - { - return method.ReturnType; - } - - return property.GetSetMethod(true)!.GetParameters()[0].ParameterType; - } - - private void CheckGenericParameters(NullabilityInfo nullability, MemberInfo metaMember, Type metaType, Type? reflectedType) - { - if (metaType.IsGenericParameter) - { - if (nullability.ReadState == NullabilityState.NotNull) - { - _ = TryUpdateGenericParameterNullability(nullability, metaType, reflectedType); - } - } - else if (metaType.ContainsGenericParameters) - { - if (nullability.GenericTypeArguments.Length > 0) - { - Type[] genericArguments = metaType.GetGenericArguments(); - - for (int i = 0; i < genericArguments.Length; i++) - { - CheckGenericParameters(nullability.GenericTypeArguments[i], metaMember, genericArguments[i], reflectedType); - } - } - else if (nullability.ElementType is { } elementNullability && metaType.IsArray) - { - CheckGenericParameters(elementNullability, metaMember, metaType.GetElementType()!, reflectedType); - } - - // We could also follow this branch for metaType.IsPointer, but since pointers must be unmanaged this - // will be a no-op regardless - else if (metaType.IsByRef) - { - CheckGenericParameters(nullability, metaMember, metaType.GetElementType()!, reflectedType); - } - } - } - - private bool TryUpdateGenericParameterNullability(NullabilityInfo nullability, Type genericParameter, Type? reflectedType) - { - Debug.Assert(genericParameter.IsGenericParameter, "must be generic parameter"); - - if (reflectedType is not null - && !genericParameter.IsGenericMethodParameter() - && TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, reflectedType, reflectedType)) - { - return true; - } - - if (IsValueTypeOrValueTypeByRef(nullability.Type)) - { - return true; - } - - var state = NullabilityState.Unknown; - if (CreateParser(genericParameter.GetCustomAttributesData()).ParseNullableState(0, ref state)) - { - nullability.ReadState = state; - nullability.WriteState = state; - return true; - } - - if (GetNullableContext(genericParameter) is { } contextState) - { - nullability.ReadState = contextState; - nullability.WriteState = contextState; - return true; - } - - return false; - } - - private bool TryUpdateGenericTypeParameterNullabilityFromReflectedType(NullabilityInfo nullability, Type genericParameter, Type context, Type reflectedType) - { - Debug.Assert(genericParameter.IsGenericParameter && !genericParameter.IsGenericMethodParameter(), "must be generic parameter"); - - Type contextTypeDefinition = context.IsGenericType && !context.IsGenericTypeDefinition ? context.GetGenericTypeDefinition() : context; - if (genericParameter.DeclaringType == contextTypeDefinition) - { - return false; - } - - Type? baseType = contextTypeDefinition.BaseType; - if (baseType is null) - { - return false; - } - - if (!baseType.IsGenericType - || (baseType.IsGenericTypeDefinition ? baseType : baseType.GetGenericTypeDefinition()) != genericParameter.DeclaringType) - { - return TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, baseType, reflectedType); - } - - Type[] genericArguments = baseType.GetGenericArguments(); - Type genericArgument = genericArguments[genericParameter.GenericParameterPosition]; - if (genericArgument.IsGenericParameter) - { - return TryUpdateGenericParameterNullability(nullability, genericArgument, reflectedType); - } - - NullableAttributeStateParser parser = CreateParser(contextTypeDefinition.GetCustomAttributesData()); - int nullabilityStateIndex = 1; // start at 1 since index 0 is the type itself - for (int i = 0; i < genericParameter.GenericParameterPosition; i++) - { - nullabilityStateIndex += CountNullabilityStates(genericArguments[i]); - } - - return TryPopulateNullabilityInfo(nullability, parser, ref nullabilityStateIndex); - - static int CountNullabilityStates(Type type) - { - Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; - if (underlyingType.IsGenericType) - { - int count = 1; - foreach (Type genericArgument in underlyingType.GetGenericArguments()) - { - count += CountNullabilityStates(genericArgument); - } - - return count; - } - - if (underlyingType.HasElementType) - { - return (underlyingType.IsArray ? 1 : 0) + CountNullabilityStates(underlyingType.GetElementType()!); - } - - return type.IsValueType ? 0 : 1; - } - } - -#pragma warning disable SA1204 // Static elements should appear before instance elements - private static bool TryPopulateNullabilityInfo(NullabilityInfo nullability, NullableAttributeStateParser parser, ref int index) -#pragma warning restore SA1204 // Static elements should appear before instance elements - { - bool isValueType = IsValueTypeOrValueTypeByRef(nullability.Type); - if (!isValueType) - { - var state = NullabilityState.Unknown; - if (!parser.ParseNullableState(index, ref state)) - { - return false; - } - - nullability.ReadState = state; - nullability.WriteState = state; - } - - if (!isValueType || (Nullable.GetUnderlyingType(nullability.Type) ?? nullability.Type).IsGenericType) - { - index++; - } - - if (nullability.GenericTypeArguments.Length > 0) - { - foreach (NullabilityInfo genericTypeArgumentNullability in nullability.GenericTypeArguments) - { - _ = TryPopulateNullabilityInfo(genericTypeArgumentNullability, parser, ref index); - } - } - else if (nullability.ElementType is { } elementTypeNullability) - { - _ = TryPopulateNullabilityInfo(elementTypeNullability, parser, ref index); - } - - return true; - } - - private static NullabilityState TranslateByte(object? value) - { - return value is byte b ? TranslateByte(b) : NullabilityState.Unknown; - } - - private static NullabilityState TranslateByte(byte b) => - b switch - { - 1 => NullabilityState.NotNull, - 2 => NullabilityState.Nullable, - _ => NullabilityState.Unknown - }; - - private static bool IsValueTypeOrValueTypeByRef(Type type) => - type.IsValueType || ((type.IsByRef || type.IsPointer) && type.GetElementType()!.IsValueType); - - private readonly struct NullableAttributeStateParser - { - private static readonly object UnknownByte = (byte)0; - - private readonly object? _nullableAttributeArgument; - - public NullableAttributeStateParser(object? nullableAttributeArgument) - { - _nullableAttributeArgument = nullableAttributeArgument; - } - - public static NullableAttributeStateParser Unknown => new(UnknownByte); - - public bool ParseNullableState(int index, ref NullabilityState state) - { - switch (_nullableAttributeArgument) - { - case byte b: - state = TranslateByte(b); - return true; - case ReadOnlyCollection args - when index < args.Count && args[index].Value is byte elementB: - state = TranslateByte(elementB); - return true; - default: - return false; - } - } - } - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs deleted file mode 100644 index 1ee573a0020..00000000000 --- a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; - -#pragma warning disable IDE1006 // Naming Styles -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - -namespace System.Reflection -{ - /// - /// Polyfills for System.Private.CoreLib internals. - /// - [ExcludeFromCodeCoverage] - internal static class NullabilityInfoHelpers - { - public static MemberInfo GetMemberWithSameMetadataDefinitionAs(Type type, MemberInfo member) - { - const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; - foreach (var info in type.GetMembers(all)) - { - if (info.HasSameMetadataDefinitionAs(member)) - { - return info; - } - } - - throw new MissingMemberException(type.FullName, member.Name); - } - - // https://github.com/dotnet/runtime/blob/main/src/coreclr/System.Private.CoreLib/src/System/Reflection/MemberInfo.Internal.cs - public static bool HasSameMetadataDefinitionAs(this MemberInfo target, MemberInfo other) - { - return target.MetadataToken == other.MetadataToken && - target.Module.Equals(other.Module); - } - - // https://github.com/dotnet/runtime/issues/23493 - public static bool IsGenericMethodParameter(this Type target) - { - return target.IsGenericParameter && - target.DeclaringMethod != null; - } - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/README.md b/src/Shared/JsonSchemaExporter/README.md deleted file mode 100644 index 1a4d13c5841..00000000000 --- a/src/Shared/JsonSchemaExporter/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# JsonSchemaExporter - -Provides a polyfill for the [.NET 9 `JsonSchemaExporter` component](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/extract-schema) that is compatible with all supported targets using System.Text.Json version 8. - -To use this in your project, add the following to your `.csproj` file: - -```xml - - true - -``` diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs index 368e11b44d4..09f3600e522 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs @@ -9,6 +9,7 @@ using Xunit; #pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable MEAI0001 // Suppress experimental warnings for testing namespace Microsoft.Extensions.AI; @@ -450,7 +451,7 @@ public async Task ToChatResponse_UpdatesProduceMultipleResponseMessages(bool use { ChatResponseUpdate[] updates = [ - + // First message - ID "msg1", AuthorName "Assistant" new(null, "Hi! ") { CreatedAt = new DateTimeOffset(2023, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" }, new(ChatRole.Assistant, "Hello") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" }, @@ -822,6 +823,99 @@ await YieldAsync(updates).ToChatResponseAsync() : Assert.Equal(expected, response.CreatedAt); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_CoalescesImageGenerationToolResultContent(bool useAsync) + { + // Create test image content with actual byte arrays + var image1 = new DataContent((byte[])[1, 2, 3, 4], "image/png") { Name = "image1.png" }; + var image2 = new DataContent((byte[])[5, 6, 7, 8], "image/jpeg") { Name = "image2.jpg" }; + var image3 = new DataContent((byte[])[9, 10, 11, 12], "image/png") { Name = "image3.png" }; + var image4 = new DataContent((byte[])[13, 14, 15, 16], "image/gif") { Name = "image4.gif" }; + + ChatResponseUpdate[] updates = + { + new(null, "Let's generate"), + new(null, " some images"), + + // Initial ImageGenerationToolResultContent with ID "img1" + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img1", Outputs = [image1] }] }, + + // Another ImageGenerationToolResultContent with different ID "img2" + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img2", Outputs = [image2] }] }, + + // Another ImageGenerationToolResultContent with same ID "img1" - should replace the first one + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img1", Outputs = [image3] }] }, + + // ImageGenerationToolResultContent with same ID "img2" - should replace the second one + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img2", Outputs = [image4] }] }, + + // Final text + new(null, "Here are those generated images"), + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + + // Should have 4 content items: 1 text (coalesced) + 2 image results (coalesced) + 1 text + Assert.Equal(4, message.Contents.Count); + + // Verify text content was coalesced properly + Assert.Equal("Let's generate some images", + Assert.IsType(message.Contents[0]).Text); + + // Get the image result contents + var imageResults = message.Contents.OfType().ToArray(); + Assert.Equal(2, imageResults.Length); + + // Verify the first image result (ID "img1") has the latest content (image3) + var firstImageResult = imageResults.First(ir => ir.ImageId == "img1"); + Assert.NotNull(firstImageResult.Outputs); + var firstOutput = Assert.Single(firstImageResult.Outputs); + Assert.Same(image3, firstOutput); // Should be the later image, not image1 + + // Verify the second image result (ID "img2") has the latest content (image4) + var secondImageResult = imageResults.First(ir => ir.ImageId == "img2"); + Assert.NotNull(secondImageResult.Outputs); + var secondOutput = Assert.Single(secondImageResult.Outputs); + Assert.Same(image4, secondOutput); // Should be the later image, not image2 + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_ImageGenerationToolResultContentWithNullOrEmptyImageId_DoesNotCoalesce(bool useAsync) + { + var image1 = new DataContent((byte[])[1, 2, 3, 4], "image/png") { Name = "image1.png" }; + var image2 = new DataContent((byte[])[5, 6, 7, 8], "image/jpeg") { Name = "image2.jpg" }; + var image3 = new DataContent((byte[])[9, 10, 11, 12], "image/png") { Name = "image3.png" }; + + ChatResponseUpdate[] updates = + { + // ImageGenerationToolResultContent with null ImageId - should not coalesce + new() { Contents = [new ImageGenerationToolResultContent { ImageId = null, Outputs = [image1] }] }, + + // ImageGenerationToolResultContent with empty ImageId - should not coalesce + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "", Outputs = [image2] }] }, + + // Another with null ImageId - should not coalesce with the first + new() { Contents = [new ImageGenerationToolResultContent { ImageId = null, Outputs = [image3] }] }, + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + + // Should have all 3 image result contents since they can't be coalesced + var imageResults = message.Contents.OfType().ToArray(); + Assert.Equal(3, imageResults.Length); + + // Verify each has its original content + Assert.Same(image1, imageResults[0].Outputs![0]); + Assert.Same(image2, imageResults[1].Outputs![0]); + Assert.Same(image3, imageResults[2].Outputs![0]); + } + private static async IAsyncEnumerable YieldAsync(IEnumerable updates) { foreach (ChatResponseUpdate update in updates) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs index 413215d9a44..9727a58ac47 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs @@ -167,4 +167,165 @@ public void JsonSerialization_Roundtrips() Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); } + + [Fact] + public void Clone_CreatesShallowCopy() + { + // Arrange + var originalAdditionalProperties = new AdditionalPropertiesDictionary { ["key"] = "value" }; + var originalContents = new List { new TextContent("text1"), new TextContent("text2") }; + var originalRawRepresentation = new object(); + var originalCreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero); + + var original = new ChatResponseUpdate + { + AdditionalProperties = originalAdditionalProperties, + AuthorName = "author", + Contents = originalContents, + CreatedAt = originalCreatedAt, + ConversationId = "conv123", + FinishReason = ChatFinishReason.ContentFilter, + MessageId = "msg456", + ModelId = "model789", + RawRepresentation = originalRawRepresentation, + ResponseId = "resp012", + Role = ChatRole.Assistant, + }; + + // Act + var clone = original.Clone(); + + // Assert - Different instances + Assert.NotSame(original, clone); + + // Assert - All properties copied correctly + Assert.Equal(original.AuthorName, clone.AuthorName); + Assert.Equal(original.Role, clone.Role); + Assert.Equal(original.CreatedAt, clone.CreatedAt); + Assert.Equal(original.ConversationId, clone.ConversationId); + Assert.Equal(original.FinishReason, clone.FinishReason); + Assert.Equal(original.MessageId, clone.MessageId); + Assert.Equal(original.ModelId, clone.ModelId); + Assert.Equal(original.ResponseId, clone.ResponseId); + + // Assert - Reference properties are shallow copied (same references) + Assert.Same(original.AdditionalProperties, clone.AdditionalProperties); + Assert.Same(original.Contents, clone.Contents); + Assert.Same(original.RawRepresentation, clone.RawRepresentation); + } + + [Fact] + public void Clone_WithNullProperties_CopiesCorrectly() + { + // Arrange + var original = new ChatResponseUpdate + { + Role = ChatRole.User, + ResponseId = "resp123" + }; + + // Act + var clone = original.Clone(); + + // Assert + Assert.NotSame(original, clone); + Assert.Equal(ChatRole.User, clone.Role); + Assert.Equal("resp123", clone.ResponseId); + Assert.Null(clone.AdditionalProperties); + Assert.Null(clone.AuthorName); + Assert.Null(clone.CreatedAt); + Assert.Null(clone.ConversationId); + Assert.Null(clone.FinishReason); + Assert.Null(clone.MessageId); + Assert.Null(clone.ModelId); + Assert.Null(clone.RawRepresentation); + Assert.Empty(clone.Contents); // Contents property initializes to empty list + } + + [Fact] + public void Clone_WithDefaultConstructor_CopiesCorrectly() + { + // Arrange + var original = new ChatResponseUpdate(); + + // Act + var clone = original.Clone(); + + // Assert + Assert.NotSame(original, clone); + Assert.Null(clone.AuthorName); + Assert.Null(clone.Role); + Assert.Empty(clone.Contents); + Assert.Null(clone.RawRepresentation); + Assert.Null(clone.AdditionalProperties); + Assert.Null(clone.ResponseId); + Assert.Null(clone.MessageId); + Assert.Null(clone.CreatedAt); + Assert.Null(clone.FinishReason); + Assert.Null(clone.ConversationId); + Assert.Null(clone.ModelId); + } + + [Fact] + public void Clone_ModifyingClone_DoesNotAffectOriginal() + { + // Arrange + var original = new ChatResponseUpdate + { + AuthorName = "original_author", + Role = ChatRole.User, + ResponseId = "original_id", + ModelId = "original_model" + }; + + // Act + var clone = original.Clone(); + clone.AuthorName = "modified_author"; + clone.Role = ChatRole.Assistant; + clone.ResponseId = "modified_id"; + clone.ModelId = "modified_model"; + + // Assert - Original remains unchanged + Assert.Equal("original_author", original.AuthorName); + Assert.Equal(ChatRole.User, original.Role); + Assert.Equal("original_id", original.ResponseId); + Assert.Equal("original_model", original.ModelId); + + // Assert - Clone has modified values + Assert.Equal("modified_author", clone.AuthorName); + Assert.Equal(ChatRole.Assistant, clone.Role); + Assert.Equal("modified_id", clone.ResponseId); + Assert.Equal("modified_model", clone.ModelId); + } + + [Fact] + public void Clone_ModifyingSharedReferences_AffectsBothInstances() + { + // Arrange + var sharedAdditionalProperties = new AdditionalPropertiesDictionary { ["initial"] = "value" }; + var sharedContents = new List { new TextContent("initial") }; + + var original = new ChatResponseUpdate + { + AdditionalProperties = sharedAdditionalProperties, + Contents = sharedContents + }; + + // Act + var clone = original.Clone(); + + // Modify the shared reference objects + sharedAdditionalProperties["modified"] = "new_value"; + sharedContents.Add(new TextContent("added")); + + // Assert - Both original and clone are affected due to shallow copy + Assert.Same(original.AdditionalProperties, clone.AdditionalProperties); + Assert.Same(original.Contents, clone.Contents); + Assert.Equal(2, original.AdditionalProperties.Count); + Assert.Equal(2, clone.AdditionalProperties?.Count); + Assert.Equal(2, original.Contents.Count); + Assert.Equal(2, clone.Contents.Count); + Assert.True(original.AdditionalProperties.ContainsKey("modified")); + Assert.True(clone.AdditionalProperties?.ContainsKey("modified")); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index eb81dd90ecb..8ebc20b957e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -362,9 +362,7 @@ public static void CreateFunctionJsonSchema_TreatsIntegralTypesAsInteger_EvenWit JsonElement schemaParameters = func.JsonSchema.GetProperty("properties"); Assert.NotNull(func.UnderlyingMethod); ParameterInfo[] parameters = func.UnderlyingMethod.GetParameters(); -#if NET9_0_OR_GREATER Assert.Equal(parameters.Length, schemaParameters.GetPropertyCount()); -#endif int i = 0; foreach (JsonProperty property in schemaParameters.EnumerateObject()) @@ -1434,13 +1432,7 @@ private partial class JsonContext : JsonSerializerContext; private static bool DeepEquals(JsonElement element1, JsonElement element2) { -#if NET9_0_OR_GREATER return JsonElement.DeepEquals(element1, element2); -#else - return JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(element1, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(element2, AIJsonUtilities.DefaultOptions)); -#endif } private static void AssertDeepEquals(JsonElement element1, JsonElement element2) diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..3a05f7fba9c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,15 @@ +// 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; + +/// +/// Azure AI Inference-specific integration tests for ImageGeneratingChatClient. +/// Tests the ImageGeneratingChatClient with Azure AI Inference chat client implementation. +/// +public class AzureAIInferenceImageGeneratingChatClientIntegrationTests : ImageGeneratingChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetChatCompletionsClient() + ?.AsIChatClient(TestRunnerConfiguration.Instance["AzureAIInference:ChatModel"] ?? "gpt-4o-mini"); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..2cbdcd96abf --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,448 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +#pragma warning disable CA2000 // Dispose objects before losing scope +#pragma warning disable CA2214 // Do not call overridable methods in constructors + +namespace Microsoft.Extensions.AI; + +/// +/// Abstract base class for integration tests that verify ImageGeneratingChatClient with real IChatClient implementations. +/// Concrete test classes should inherit from this and provide a real IChatClient that supports function calling. +/// +public abstract class ImageGeneratingChatClientIntegrationTests : IDisposable +{ + private const string ImageKey = "meai_image"; + private readonly IChatClient? _baseChatClient; + + protected ImageGeneratingChatClientIntegrationTests() + { + _baseChatClient = CreateChatClient(); + ImageGenerator = new(); + + if (_baseChatClient != null) + { + ChatClient = _baseChatClient + .AsBuilder() + .UseImageGeneration(ImageGenerator) + .UseFunctionInvocation() + .Build(); + } + } + + /// Gets the ImageGeneratingChatClient configured with function invocation support. + protected IChatClient? ChatClient { get; } + + /// Gets the IImageGenerator used for testing. + protected CapturingImageGenerator ImageGenerator { get; } + + public void Dispose() + { + ChatClient?.Dispose(); + _baseChatClient?.Dispose(); + ImageGenerator.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Creates the base IChatClient implementation to test with. + /// Should return a real chat client that supports function calling. + /// + /// An IChatClient instance, or null to skip tests. + protected abstract IChatClient? CreateChatClient(); + + /// + /// Helper method to get a chat response using either streaming or non-streaming based on the parameter. + /// + /// Whether to use streaming or non-streaming response. + /// The chat messages to send. + /// The chat options to use. + /// A ChatResponse from either streaming or non-streaming call. + protected async Task GetResponseAsync(bool useStreaming, IEnumerable messages, ChatOptions? options = null, IChatClient? chatClient = null) + { + chatClient ??= ChatClient ?? throw new InvalidOperationException("ChatClient is not initialized."); + + if (useStreaming) + { + return ValidateChatResponse(await chatClient.GetStreamingResponseAsync(messages, options).ToChatResponseAsync()); + } + else + { + return ValidateChatResponse(await chatClient.GetResponseAsync(messages, options)); + } + + static ChatResponse ValidateChatResponse(ChatResponse response) + { + var contents = response.Messages.SelectMany(m => m.Contents).ToArray(); + + List imageIds = []; + foreach (var toolResult in contents.OfType()) + { + Assert.NotNull(toolResult.Outputs); + + foreach (var dataContent in toolResult.Outputs.OfType()) + { + var imageId = dataContent.AdditionalProperties?[ImageKey] as string; + Assert.NotNull(imageId); + imageIds.Add(imageId); + } + } + + foreach (var textContent in contents.OfType()) + { + Assert.DoesNotContain(ImageKey, textContent.Text, StringComparison.OrdinalIgnoreCase); + foreach (var imageId in imageIds) + { + // Ensure no image IDs appear in text content + Assert.DoesNotContain(imageId, textContent.Text, StringComparison.OrdinalIgnoreCase); + } + } + + return response; + } + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task GenerateImage_CallsGenerateFunction_ReturnsDataContent(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await GetResponseAsync(useStreaming, + [new ChatMessage(ChatRole.User, "Please generate an image of a cat")], + chatOptions); + + // Assert + Assert.Single(imageGenerator.GenerateCalls); + var (request, _) = imageGenerator.GenerateCalls[0]; + Assert.Contains("cat", request.Prompt, StringComparison.OrdinalIgnoreCase); + Assert.Null(request.OriginalImages); // Generation, not editing + + // Verify that we get ImageGenerationToolResultContent back in the response + var imageResults = response.Messages + .SelectMany(m => m.Contents) + .OfType(); + + var imageResult = Assert.Single(imageResults); + Assert.NotNull(imageResult.Outputs); + var imageContent = Assert.Single(imageResult.Outputs.OfType()); + Assert.Equal("image/png", imageContent.MediaType); + Assert.False(imageContent.Data.IsEmpty); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task EditImage_WithImageInSameRequest_PassesExactDataContent(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var testImageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG header + var originalImageData = new DataContent(testImageData, "image/png") { Name = "original.png" }; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await GetResponseAsync(useStreaming, + [new ChatMessage(ChatRole.User, [new TextContent("Please edit this image to add a red border"), originalImageData])], + chatOptions); + + // Assert + var (request, _) = Assert.Single(imageGenerator.GenerateCalls); + Assert.NotNull(request.OriginalImages); + + var originalImage = Assert.Single(request.OriginalImages); + var originalImageContent = Assert.IsType(originalImage); + Assert.Equal(testImageData, originalImageContent.Data.ToArray()); + Assert.Equal("image/png", originalImageContent.MediaType); + Assert.Equal("original.png", originalImageContent.Name); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task GenerateThenEdit_FromChatHistory_EditsGeneratedImage(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + var chatHistory = new List + { + new(ChatRole.User, "Please generate an image of a dog") + }; + + // First request: Generate image + var firstResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(firstResponse.Messages); + + // Second request: Edit the generated image + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit the image to make it more colorful")); + var secondResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + + // Assert + Assert.Equal(2, imageGenerator.GenerateCalls.Count); + + // First call should be generation (no original images) + var (firstRequest, _) = imageGenerator.GenerateCalls[0]; + Assert.Null(firstRequest.OriginalImages); + + // Extract the DataContent from the ImageGenerationToolResultContent + var firstToolResultContent = Assert.Single(firstResponse.Messages.SelectMany(m => m.Contents).OfType()); + Assert.NotNull(firstToolResultContent.Outputs); + var firstContent = Assert.Single(firstToolResultContent.Outputs.OfType()); + + // Second call should be editing (with original images) + var (secondRequest, _) = imageGenerator.GenerateCalls[1]; + Assert.Single(secondResponse.Messages.SelectMany(m => m.Contents).OfType().SelectMany(t => t.Outputs!.OfType())); + Assert.NotNull(secondRequest.OriginalImages); + var editContent = Assert.Single(secondRequest.OriginalImages); + Assert.Equal(firstContent, editContent); // Should be the same image as generated in first call + + var editedImage = Assert.IsType(secondRequest.OriginalImages.First()); + Assert.Equal("image/png", editedImage.MediaType); + Assert.Contains("generated_image_1", editedImage.Name); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task MultipleEdits_EditsLatestImage(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + var chatHistory = new List + { + new(ChatRole.User, "Please generate an image of a tree") + }; + + // First: Generate image + var firstResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(firstResponse.Messages); + + // Second: First edit + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit the image to add flowers")); + var secondResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(secondResponse.Messages); + + // Third: Second edit (should edit the latest version by default) + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit that last image to add birds")); + var thirdResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + + // Assert + Assert.Equal(3, imageGenerator.GenerateCalls.Count); + + // Third call should edit the second generated image (from first edit), not the original + var (thirdRequest, _) = imageGenerator.GenerateCalls[2]; + Assert.NotNull(thirdRequest.OriginalImages); + + // Extract the DataContent from the second response's ImageGenerationToolResultContent + var secondToolResultContent = Assert.Single(secondResponse.Messages.SelectMany(m => m.Contents).OfType()); + var secondImage = Assert.Single(secondToolResultContent.Outputs!.OfType()); + var lastImageToEdit = Assert.Single(thirdRequest.OriginalImages.OfType()); + Assert.Equal(secondImage, lastImageToEdit); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task MultipleEdits_EditsFirstImage(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + var chatHistory = new List + { + new(ChatRole.User, "Please generate an image of a tree") + }; + + // First: Generate image + var firstResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(firstResponse.Messages); + + // Second: First edit + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit the image to add fruit")); + var secondResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(secondResponse.Messages); + + // Third: Second edit (should edit the latest version by default) + chatHistory.Add(new ChatMessage(ChatRole.User, "That didn't work out. Please edit the original image to add birds")); + var thirdResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + + // Assert + Assert.Equal(3, imageGenerator.GenerateCalls.Count); + + // Third call should edit the original generated image (not from edit) + var (thirdRequest, _) = imageGenerator.GenerateCalls[2]; + Assert.NotNull(thirdRequest.OriginalImages); + + // Extract the DataContent from the first response's ImageGenerationToolResultContent + var firstToolResultContent = Assert.Single(firstResponse.Messages.SelectMany(m => m.Contents).OfType()); + var firstGeneratedImage = Assert.Single(firstToolResultContent.Outputs!.OfType()); + var lastImageToEdit = Assert.IsType(thirdRequest.OriginalImages.First()); + Assert.Equal(firstGeneratedImage, lastImageToEdit); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task ImageGeneration_WithOptions_PassesOptionsToGenerator(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var imageGenerationOptions = new ImageGenerationOptions + { + Count = 2, + ImageSize = new System.Drawing.Size(512, 512) + }; + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool { Options = imageGenerationOptions }] + }; + + // Act + var response = await GetResponseAsync(useStreaming, + [new ChatMessage(ChatRole.User, "Generate an image of a castle")], + chatOptions); + + // Assert + Assert.Single(imageGenerator.GenerateCalls); + var (_, options) = imageGenerator.GenerateCalls[0]; + Assert.NotNull(options); + Assert.Equal(2, options.Count); + Assert.Equal(new System.Drawing.Size(512, 512), options.ImageSize); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task ImageContentHandling_AllImages_ReplacesImagesWithPlaceholders(bool useStreaming) + { + SkipIfNotEnabled(); + + var testImageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG header + var capturedMessages = new List>(); + + // Create a new ImageGeneratingChatClient with AllImages data content handling + using var imageGeneratingClient = _baseChatClient! + .AsBuilder() + .UseImageGeneration(ImageGenerator) + .Use((messages, options, next, cancellationToken) => + { + capturedMessages.Add(messages); + return next(messages, options, cancellationToken); + }) + .UseFunctionInvocation() + .Build(); + + var originalImage = new DataContent(testImageData, "image/png") { Name = "test.png" }; + + // Act + await GetResponseAsync(useStreaming, + [ + new ChatMessage(ChatRole.User, + [ + new TextContent("Here's an image to process"), + originalImage + ]) + ], + new ChatOptions { Tools = [new HostedImageGenerationTool()] }, + imageGeneratingClient); + + // Assert + Assert.NotEmpty(capturedMessages); + var processedMessages = capturedMessages.First().ToList(); + var userMessage = processedMessages.First(m => m.Role == ChatRole.User); + + // Should have text content with placeholder instead of original image + var textContents = userMessage.Contents.OfType().ToList(); + Assert.Contains(textContents, tc => tc.Text.Contains(ImageKey) && tc.Text.Contains("] available for edit")); + + // Should not contain the original DataContent + Assert.DoesNotContain(userMessage.Contents, c => c == originalImage); + } + + /// + /// Test image generator that captures calls and returns fake image data. + /// + protected sealed class CapturingImageGenerator : IImageGenerator + { + private const string TestImageMediaType = "image/png"; + private static readonly byte[] _testImageData = [0x89, 0x50, 0x4E, 0x47]; // PNG header + + public List<(ImageGenerationRequest request, ImageGenerationOptions? options)> GenerateCalls { get; } = []; + public int ImageCounter { get; private set; } + + public Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + GenerateCalls.Add((request, options)); + + // Create fake image data with unique content + var imageData = new byte[_testImageData.Length + 4]; + _testImageData.CopyTo(imageData, 0); + BitConverter.GetBytes(++ImageCounter).CopyTo(imageData, _testImageData.Length); + + var imageContent = new DataContent(imageData, TestImageMediaType) + { + Name = $"generated_image_{ImageCounter}.png" + }; + + return Task.FromResult(new ImageGenerationResponse([imageContent])); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + // No resources to dispose + } + } + + [MemberNotNull(nameof(ChatClient))] + protected void SkipIfNotEnabled() + { + string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"]; + + if (skipIntegration is not null || ChatClient is null) + { + throw new SkipTestException("Client is not enabled."); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj index 0fc4698c4e4..bd6c6b9ba2f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj @@ -46,7 +46,6 @@ - diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..7a6f60af778 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,19 @@ +// 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 OllamaSharp; + +namespace Microsoft.Extensions.AI; + +/// +/// OllamaSharp-specific integration tests for ImageGeneratingChatClient. +/// Tests the ImageGeneratingChatClient with OllamaSharp chat client implementation. +/// +public class OllamaSharpImageGeneratingChatClientIntegrationTests : ImageGeneratingChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? + new OllamaApiClient(endpoint, "llama3.2") : + null; +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..7f9e6195aa3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,15 @@ +// 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; + +/// +/// OpenAI-specific integration tests for ImageGeneratingChatClient. +/// Tests the ImageGeneratingChatClient with OpenAI's chat client implementation. +/// +public class OpenAIImageGeneratingChatClientIntegrationTests : ImageGeneratingChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetOpenAIClient() + ?.GetChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini").AsIChatClient(); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 53997c790e0..1ee738bd6a0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -4640,6 +4640,356 @@ public async Task ResponseWithRefusalContent_ParsesCorrectly() Assert.Equal("Refusal", errorContent.ErrorCode); } + [Fact] + public async Task HostedImageGenerationTool_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o", + "tools": [ + { + "type": "image_generation", + "model": "gpt-image-1", + "size": "1024x1024", + "output_format": "png" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Generate an image of a cat" + } + ] + } + ] + } + """; + + const string Output = """ + { + "id": "resp_abc123", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-2024-11-20", + "output": [ + { + "type": "image_generation_call", + "id": "img_call_abc123", + "result": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + } + ], + "usage": { + "input_tokens": 15, + "output_tokens": 0, + "total_tokens": 15 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o"); + + var imageTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + ImageSize = new(1024, 1024), + MediaType = "image/png" + } + }; + var response = await client.GetResponseAsync("Generate an image of a cat", new ChatOptions + { + Tools = [imageTool] + }); + + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + + var contents = response.Messages[0].Contents; + Assert.Equal(2, contents.Count); + + // First content should be the tool call + var toolCall = contents[0] as ImageGenerationToolCallContent; + Assert.NotNull(toolCall); + Assert.Equal("img_call_abc123", toolCall.ImageId); + + // Second content should be the result with image data + var toolResult = contents[1] as ImageGenerationToolResultContent; + Assert.NotNull(toolResult); + Assert.Equal("img_call_abc123", toolResult.ImageId); + Assert.Single(toolResult.Outputs!); + + var imageData = toolResult.Outputs![0] as DataContent; + Assert.NotNull(imageData); + Assert.Equal("image/png", imageData.MediaType); + Assert.True(imageData.Data.Length > 0); + } + + [Fact] + public async Task HostedImageGenerationTool_Streaming() + { + const string Input = """ + { + "model": "gpt-4o", + "tools": [ + { + "type": "image_generation", + "model": "gpt-image-1", + "size": "1024x1024", + "output_format": "png" + } + ], + "tool_choice": "auto", + "stream": true, + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Generate an image of a dog" + } + ] + } + ] + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_def456","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[],"tools":[{"type":"image_generation","image_generation":{"model":"gpt-image-1","size":{"width":1024,"height":1024},"output_format":"png"}}]}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_def456","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"image_generation_call","id":"img_call_def456","status":"in_progress"}} + + event: response.image_generation_call.in_progress + data: {"type":"response.image_generation_call.in_progress","item_id":"img_call_def456","output_index":0} + + event: response.image_generation_call.generating + data: {"type":"response.image_generation_call.generating","item_id":"img_call_def456","output_index":0} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_def456","output_index":0,"partial_image_index":0,"partial_image_b64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"image_generation_call","id":"img_call_def456","image_result_b64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_def456","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-2024-11-20","output":[{"type":"image_generation_call","id":"img_call_def456","image_result_b64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}],"usage":{"input_tokens":15,"output_tokens":0,"total_tokens":15}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o"); + + List updates = []; + var imageTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + ImageSize = new(1024, 1024), + MediaType = "image/png" + } + }; + await foreach (var update in client.GetStreamingResponseAsync("Generate an image of a dog", new ChatOptions + { + Tools = [imageTool] + })) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 6); + + // Should have updates for: created, in_progress, tool call start, generating, partial image, completion + var createdUpdate = updates.First(u => u.CreatedAt.HasValue); + Assert.Equal("resp_def456", createdUpdate.ResponseId); + Assert.Equal("gpt-4o-2024-11-20", createdUpdate.ModelId); + + // Should have tool call content + var toolCallUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolCallContent)); + Assert.NotNull(toolCallUpdate); + var toolCall = toolCallUpdate.Contents.OfType().First(); + Assert.Equal("img_call_def456", toolCall.ImageId); + + // Should have partial image content + var partialImageUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolResultContent result && + result.Outputs != null && result.Outputs.Any(o => o.AdditionalProperties != null && o.AdditionalProperties.ContainsKey("PartialImageIndex")))); + Assert.NotNull(partialImageUpdate); + + // Should have final completion with usage + var completionUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is UsageContent)); + Assert.NotNull(completionUpdate); + var usage = completionUpdate.Contents.OfType().First(); + Assert.Equal(15, usage.Details.InputTokenCount); + Assert.Equal(0, usage.Details.OutputTokenCount); + Assert.Equal(15, usage.Details.TotalTokenCount); + } + + [Fact] + public async Task HostedImageGenerationTool_StreamingMultipleImages() + { + const string Input = """ + { + "model": "gpt-4o", + "tools": [ + { + "type": "image_generation", + "model": "gpt-image-1", + "size": "512x512", + "output_format": "webp", + "partial_images": 3 + } + ], + "tool_choice": "auto", + "stream": true, + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Generate an image of a sunset" + } + ] + } + ] + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_ghi789","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[],"tools":[{"type":"image_generation","image_generation":{"model":"gpt-image-1","size":{"width":512,"height":512},"output_format":"webp","partial_images":3}}]}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_ghi789","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"image_generation_call","id":"img_call_ghi789","status":"in_progress"}} + + event: response.image_generation_call.in_progress + data: {"type":"response.image_generation_call.in_progress","item_id":"img_call_ghi789","output_index":0} + + event: response.image_generation_call.generating + data: {"type":"response.image_generation_call.generating","item_id":"img_call_ghi789","output_index":0} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_ghi789","output_index":0,"partial_image_index":0,"partial_image_b64":"SGVsbG8x"} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_ghi789","output_index":0,"partial_image_index":1,"partial_image_b64":"SGVsbG8y"} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_ghi789","output_index":0,"partial_image_index":2,"partial_image_b64":"SGVsbG8z"} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"image_generation_call","id":"img_call_ghi789","image_result_b64":"SGVsbG8z"}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_ghi789","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-2024-11-20","output":[{"type":"image_generation_call","id":"img_call_ghi789","image_result_b64":"SGVsbG8z"}],"usage":{"input_tokens":18,"output_tokens":0,"total_tokens":18}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o"); + + List updates = []; + var imageTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + ImageSize = new(512, 512), + MediaType = "image/webp", + StreamingCount = 3 + } + }; + await foreach (var update in client.GetStreamingResponseAsync("Generate an image of a sunset", new ChatOptions + { + Tools = [imageTool] + })) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 8); // Should have multiple partial image updates plus generating event + + // Should have multiple partial images with different indices + var partialImageUpdates = updates.Where(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolResultContent result && + result.Outputs != null && result.Outputs.Any(o => o.AdditionalProperties != null && o.AdditionalProperties.ContainsKey("PartialImageIndex")))).ToList(); + + Assert.True(partialImageUpdates.Count >= 3); + + // Verify partial images have correct indices and WebP format + for (int i = 0; i < 3; i++) + { + var partialUpdate = partialImageUpdates.FirstOrDefault(u => + u.Contents.OfType().Any(result => + HasPartialImageWithIndex(result, i))); + Assert.NotNull(partialUpdate); + } + + static bool HasPartialImageWithIndex(ImageGenerationToolResultContent result, int index) + { + if (result.Outputs == null) + { + return false; + } + + return result.Outputs.Any(o => HasCorrectImageData(o, index)); + } + + static bool HasCorrectImageData(AIContent o, int index) + { + if (o.AdditionalProperties == null) + { + return false; + } + + if (!o.AdditionalProperties.TryGetValue("PartialImageIndex", out var imageIndex)) + { + return false; + } + + if (imageIndex == null || !imageIndex.Equals(index)) + { + return false; + } + + return o is DataContent dataContent && dataContent.MediaType == "image/webp"; + } + + // Verify tool call uses correct settings + var toolCallUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolCallContent)); + Assert.NotNull(toolCallUpdate); + var toolCall = toolCallUpdate.Contents.OfType().First(); + Assert.Equal("img_call_ghi789", toolCall.ImageId); + } + private static IChatClient CreateResponseClient(HttpClient httpClient, string modelId) => new OpenAIClient( new ApiKeyCredential("apikey"), diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index 431b2053d62..1f7e7f5084a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -482,12 +482,6 @@ Elements are not equal. private static bool DeepEquals(JsonElement element1, JsonElement element2) { -#if NET9_0_OR_GREATER return JsonElement.DeepEquals(element1, element2); -#else - return System.Text.Json.Nodes.JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(element1, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(element2, AIJsonUtilities.DefaultOptions)); -#endif } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ImageGeneratingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ImageGeneratingChatClientTests.cs new file mode 100644 index 00000000000..a70d19abffc --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ImageGeneratingChatClientTests.cs @@ -0,0 +1,386 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratingChatClientTests +{ + [Fact] + public void ImageGeneratingChatClient_InvalidArgs_Throws() + { + using var innerClient = new TestChatClient(); + using var imageGenerator = new TestImageGenerator(); + + Assert.Throws("innerClient", () => new ImageGeneratingChatClient(null!, imageGenerator)); + Assert.Throws("imageGenerator", () => new ImageGeneratingChatClient(innerClient, null!)); + } + + [Fact] + public void UseImageGeneration_WithNullBuilder_Throws() + { + Assert.Throws("builder", () => ((ChatClientBuilder)null!).UseImageGeneration()); + } + + [Fact] + public async Task GetResponseAsync_WithoutImageGenerationTool_PassesThrough() + { + // Arrange + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => "dummy function", name: "DummyFunction")] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.NotNull(response); + Assert.Equal("test response", response.Messages[0].Text); + + // Verify that tools collection still has the original function, not replaced + Assert.Single(chatOptions.Tools); + Assert.IsAssignableFrom(chatOptions.Tools[0]); + } + + [Fact] + public async Task GetResponseAsync_WithImageGenerationTool_ReplacesTool() + { + // Arrange + bool innerClientCalled = false; + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + innerClientCalled = true; + capturedOptions = options; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.True(innerClientCalled); + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.Tools); + Assert.Equal(3, capturedOptions.Tools.Count); + + // Verify the functions are properly created + var generateImageFunction = capturedOptions.Tools[0] as AIFunction; + var editImageFunction = capturedOptions.Tools[1] as AIFunction; + var getImagesForEditImageFunction = capturedOptions.Tools[2] as AIFunction; + + Assert.NotNull(generateImageFunction); + Assert.NotNull(editImageFunction); + Assert.NotNull(getImagesForEditImageFunction); + Assert.Equal("GenerateImage", generateImageFunction.Name); + Assert.Equal("EditImage", editImageFunction.Name); + Assert.Equal("GetImagesForEdit", getImagesForEditImageFunction.Name); + } + + [Fact] + public async Task GetResponseAsync_WithMixedTools_ReplacesOnlyImageGenerationTool() + { + // Arrange + bool innerClientCalled = false; + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + innerClientCalled = true; + capturedOptions = options; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var dummyFunction = AIFunctionFactory.Create(() => "dummy", name: "DummyFunction"); + var chatOptions = new ChatOptions + { + Tools = [dummyFunction, new HostedImageGenerationTool()] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.True(innerClientCalled); + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.Tools); + Assert.Equal(4, capturedOptions.Tools.Count); // DummyFunction + GenerateImage + EditImage + GetImagesForEdit + + Assert.Same(dummyFunction, capturedOptions.Tools[0]); // Original function preserved + Assert.IsAssignableFrom(capturedOptions.Tools[1]); // GenerateImage function + Assert.IsAssignableFrom(capturedOptions.Tools[2]); // EditImage function + } + + [Fact] + public void UseImageGeneration_ServiceProviderIntegration_Works() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + + using var serviceProvider = services.BuildServiceProvider(); + using var innerClient = new TestChatClient(); + + // Act + using var client = innerClient + .AsBuilder() + .UseImageGeneration() + .Build(serviceProvider); + + // Assert + Assert.IsType(client); + } + + [Fact] + public void UseImageGeneration_WithProvidedImageGenerator_Works() + { + // Arrange + using var innerClient = new TestChatClient(); + using var imageGenerator = new TestImageGenerator(); + + // Act + using var client = innerClient + .AsBuilder() + .UseImageGeneration(imageGenerator) + .Build(); + + // Assert + Assert.IsType(client); + } + + [Fact] + public void UseImageGeneration_WithConfigureCallback_CallsCallback() + { + // Arrange + using var innerClient = new TestChatClient(); + using var imageGenerator = new TestImageGenerator(); + bool configureCallbackInvoked = false; + + // Act + using var client = innerClient + .AsBuilder() + .UseImageGeneration(imageGenerator, configure: c => + { + Assert.NotNull(c); + configureCallbackInvoked = true; + }) + .Build(); + + // Assert + Assert.True(configureCallbackInvoked); + } + + [Fact] + public async Task GetStreamingResponseAsync_WithImageGenerationTool_ReplacesTool() + { + // Arrange + bool innerClientCalled = false; + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + innerClientCalled = true; + capturedOptions = options; + return GetUpdatesAsync(); + } + }; + + static async IAsyncEnumerable GetUpdatesAsync() + { + await Task.Yield(); + yield return new(ChatRole.Assistant, "test"); + } + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + await foreach (var update in client.GetStreamingResponseAsync([new(ChatRole.User, "test")], chatOptions)) + { + // Process updates + } + + // Assert + Assert.True(innerClientCalled); + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.Tools); + Assert.Equal(3, capturedOptions.Tools.Count); + } + + [Fact] + public async Task GetResponseAsync_WithNullOptions_DoesNotThrow() + { + // Arrange + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + // Act & Assert + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], null); + Assert.NotNull(response); + } + + [Fact] + public async Task GetResponseAsync_WithEmptyTools_DoesNotModify() + { + // Arrange + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + capturedOptions = options; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [] + }; + + // Act + await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.Same(chatOptions, capturedOptions); +#pragma warning disable CA1508 + Assert.NotNull(capturedOptions?.Tools); +#pragma warning restore CA1508 + Assert.Empty(capturedOptions.Tools); + } + + [Fact] + public async Task GetResponseAsync_WithFunctionCallContent_ReplacesWithImageGenerationToolCallContent() + { + // Arrange + var callId = "test-call-id"; + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + var responseMessage = new ChatMessage(ChatRole.Assistant, + [new FunctionCallContent(callId, "GenerateImage", new Dictionary { ["prompt"] = "a cat" })]); + return Task.FromResult(new ChatResponse(responseMessage)); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + var message = response.Messages[0]; + Assert.Single(message.Contents); + + var imageToolCallContent = Assert.IsType(message.Contents[0]); + Assert.Equal(callId, imageToolCallContent.ImageId); + } + + [Fact] + public async Task GetStreamingResponseAsync_WithFunctionCallContent_ReplacesWithImageGenerationToolCallContent() + { + // Arrange + var callId = "test-call-id"; + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + return GetUpdatesAsync(); + } + }; + + async IAsyncEnumerable GetUpdatesAsync() + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, + [new FunctionCallContent(callId, "GenerateImage", new Dictionary { ["prompt"] = "a cat" })]); + } + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var updates = new List(); + await foreach (var responseUpdate in client.GetStreamingResponseAsync([new(ChatRole.User, "test")], chatOptions)) + { + updates.Add(responseUpdate); + } + + // Assert + Assert.Single(updates); + var update = updates[0]; + Assert.Single(update.Contents); + + var imageToolCallContent = Assert.IsType(update.Contents[0]); + Assert.Equal(callId, imageToolCallContent.ImageId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs index e2e25aa4664..f2f0d85c458 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs @@ -211,7 +211,7 @@ async Task Verify(IAsyncEnumerable results) List ingestionResults = await results.ToListAsync(); Assert.Equal(_sampleFiles.Count, ingestionResults.Count); - Assert.All(ingestionResults, result => Assert.NotNull(result.Source)); + Assert.All(ingestionResults, result => Assert.NotEmpty(result.DocumentId)); IngestionResult ingestionResult = Assert.Single(ingestionResults.Where(result => !result.Succeeded)); Assert.IsType(ingestionResult.Exception); AssertErrorActivities(activities, expectedFailedActivitiesCount: 1); @@ -221,16 +221,6 @@ async Task Verify(IAsyncEnumerable results) } } - private class ExpectedException : Exception - { - internal const string ExceptionMessage = "An expected exception occurred."; - - public ExpectedException() - : base(ExceptionMessage) - { - } - } - private static IngestionDocumentReader CreateReader() => new MarkdownReader(); private static IngestionChunker CreateChunker() => new HeaderChunker(new(TiktokenTokenizer.CreateForModel("gpt-4"))); @@ -246,7 +236,7 @@ private static void AssertAllIngestionsSucceeded(List ingestion { Assert.NotEmpty(ingestionResults); Assert.All(ingestionResults, result => Assert.True(result.Succeeded)); - Assert.All(ingestionResults, result => Assert.NotNull(result.Source)); + Assert.All(ingestionResults, result => Assert.NotEmpty(result.DocumentId)); Assert.All(ingestionResults, result => Assert.NotNull(result.Document)); Assert.All(ingestionResults, result => Assert.Null(result.Exception)); } diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj index 9ebe4f14517..cc64014cad6 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj @@ -14,6 +14,7 @@ + @@ -26,11 +27,6 @@ - - - - - diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/AlternativeTextEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/AlternativeTextEnricherTests.cs index cc59db3f389..6dad7e4af0a 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/AlternativeTextEnricherTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/AlternativeTextEnricherTests.cs @@ -3,18 +3,23 @@ using System; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Xunit; namespace Microsoft.Extensions.DataIngestion.Processors.Tests; public class AlternativeTextEnricherTests { + private readonly ReadOnlyMemory _imageContent = new byte[256]; + [Fact] - public void ThrowsOnNullChatClient() + public void ThrowsOnNullOptions() { - Assert.Throws("chatClient", () => new ImageAlternativeTextEnricher(null!)); + Assert.Throws("options", () => new ImageAlternativeTextEnricher(null!)); } [Fact] @@ -22,7 +27,7 @@ public async Task ThrowsOnNullDocument() { using TestChatClient chatClient = new(); - ImageAlternativeTextEnricher sut = new(chatClient); + ImageAlternativeTextEnricher sut = new(new(chatClient)); await Assert.ThrowsAsync("document", async () => await sut.ProcessAsync(null!)); } @@ -31,9 +36,7 @@ public async Task ThrowsOnNullDocument() public async Task CanGenerateImageAltText() { const string PreExistingAltText = "Pre-existing alt text"; - ReadOnlyMemory imageContent = new byte[256]; - int counter = 0; string[] descriptions = { "First alt text", "Second alt text" }; using TestChatClient chatClient = new() { @@ -44,37 +47,41 @@ public async Task CanGenerateImageAltText() Assert.Equal(2, materializedMessages.Length); Assert.Equal(ChatRole.System, materializedMessages[0].Role); Assert.Equal(ChatRole.User, materializedMessages[1].Role); - var content = Assert.Single(materializedMessages[1].Contents); - DataContent dataContent = Assert.IsType(content); - Assert.Equal("image/png", dataContent.MediaType); - Assert.Equal(imageContent.ToArray(), dataContent.Data.ToArray()); + Assert.Equal(2, materializedMessages[1].Contents.Count); + + Assert.All(materializedMessages[1].Contents, content => + { + DataContent dataContent = Assert.IsType(content); + Assert.Equal("image/png", dataContent.MediaType); + Assert.Equal(_imageContent.ToArray(), dataContent.Data.ToArray()); + }); return Task.FromResult(new ChatResponse(new[] { - new ChatMessage(ChatRole.Assistant, descriptions[counter++]) + new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(new Envelope { data = descriptions })) })); } }; - ImageAlternativeTextEnricher sut = new(chatClient); + ImageAlternativeTextEnricher sut = new(new(chatClient)); IngestionDocumentImage documentImage = new($"![](nonExisting.png)") { AlternativeText = null, - Content = imageContent, + Content = _imageContent, MediaType = "image/png" }; IngestionDocumentImage tableCell = new($"![](another.png)") { AlternativeText = null, - Content = imageContent, + Content = _imageContent, MediaType = "image/png" }; IngestionDocumentImage imageWithAltText = new($"![](noChangesNeeded.png)") { AlternativeText = PreExistingAltText, - Content = imageContent, + Content = _imageContent, MediaType = "image/png" }; @@ -107,4 +114,91 @@ public async Task CanGenerateImageAltText() Assert.Same(PreExistingAltText, imageWithAltText.AlternativeText); Assert.Null(imageWithNoContent.AlternativeText); } + + [Theory] + [InlineData(1, 3)] + [InlineData(3, 7)] + [InlineData(15, 3)] + public async Task SendsOneRequestPerBatchSize(int batchSize, int batchCount) + { + int callsCount = 0; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + callsCount++; + + var materializedMessages = messages.ToArray(); + + // One system message + one User message with all the contents + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + Assert.Equal(batchSize, materializedMessages[1].Contents.Count); + + Assert.All(materializedMessages[1].Contents, content => + { + DataContent dataContent = Assert.IsType(content); + Assert.Equal("image/png", dataContent.MediaType); + Assert.Equal(_imageContent.ToArray(), dataContent.Data.ToArray()); + }); + + Envelope data = new() { data = Enumerable.Range(0, batchSize).Select(i => i.ToString()).ToArray() }; + return Task.FromResult(new ChatResponse(new[] { new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(data)) })); + } + }; + + ImageAlternativeTextEnricher sut = new(new(chatClient) { BatchSize = batchSize }); + + IngestionDocument document = CreateDocument(batchSize, batchCount, _imageContent); + + await sut.ProcessAsync(document); + Assert.Equal(batchCount, callsCount); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + EnricherOptions options = new(chatClient) { LoggerFactory = loggerFactory }; + ImageAlternativeTextEnricher sut = new(options); + + const int BatchCount = 2; + IngestionDocument document = CreateDocument(options.BatchSize, BatchCount, _imageContent); + IngestionDocument got = await sut.ProcessAsync(document); + + Assert.Equal(BatchCount, collector.Count); + Assert.All(collector.GetSnapshot(), record => + { + Assert.Equal(LogLevel.Error, record.Level); + Assert.IsType(record.Exception); + }); + } + + private static IngestionDocument CreateDocument(int batchSize, int batchCount, ReadOnlyMemory imageContent) + { + IngestionDocumentSection rootSection = new(); + for (int i = 0; i < batchSize * batchCount; i++) + { + IngestionDocumentImage image = new($"![](image{i}.png)") + { + Content = imageContent, + MediaType = "image/png", + AlternativeText = null + }; + + rootSection.Elements.Add(image); + } + + return new("batchTest") + { + Sections = { rootSection } + }; + } } diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/ClassificationEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/ClassificationEnricherTests.cs index 3f890969262..15d0a5f6152 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/ClassificationEnricherTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/ClassificationEnricherTests.cs @@ -4,8 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Xunit; namespace Microsoft.Extensions.DataIngestion.Processors.Tests; @@ -15,46 +18,40 @@ public class ClassificationEnricherTests private static readonly IngestionDocument _document = new("test"); [Fact] - public void ThrowsOnNullChatClient() + public void ThrowsOnNullOptions() { - Assert.Throws("chatClient", () => new ClassificationEnricher(null!, predefinedClasses: ["some"])); + Assert.Throws("options", () => new ClassificationEnricher(null!, predefinedClasses: ["some"])); } [Fact] public void ThrowsOnEmptyPredefinedClasses() { - Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new TestChatClient(), predefinedClasses: [])); + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: [])); } [Fact] public void ThrowsOnDuplicatePredefinedClasses() { - Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new TestChatClient(), predefinedClasses: ["same", "same"])); + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: ["same", "same"])); } [Fact] public void ThrowsOnPredefinedClassesContainingFallback() { - Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new TestChatClient(), predefinedClasses: ["same", "Unknown"])); + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: ["same", "Unknown"])); } [Fact] public void ThrowsOnFallbackInPredefinedClasses() { - Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new TestChatClient(), predefinedClasses: ["some"], fallbackClass: "some")); - } - - [Fact] - public void ThrowsOnPredefinedClassesContainingComma() - { - Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new TestChatClient(), predefinedClasses: ["n,t"])); + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: ["some"], fallbackClass: "some")); } [Fact] public async Task ThrowsOnNullChunks() { using TestChatClient chatClient = new(); - ClassificationEnricher sut = new(chatClient, predefinedClasses: ["some"]); + ClassificationEnricher sut = new(new(chatClient), predefinedClasses: ["some"]); await Assert.ThrowsAsync("chunks", async () => { @@ -74,19 +71,21 @@ public async Task CanClassify() { GetResponseAsyncCallback = (messages, options, cancellationToken) => { + Assert.Equal(0, counter++); var materializedMessages = messages.ToArray(); Assert.Equal(2, materializedMessages.Length); Assert.Equal(ChatRole.System, materializedMessages[0].Role); Assert.Equal(ChatRole.User, materializedMessages[1].Role); + string response = JsonSerializer.Serialize(new Envelope { data = classes }); return Task.FromResult(new ChatResponse(new[] { - new ChatMessage(ChatRole.Assistant, classes[counter++]) + new ChatMessage(ChatRole.Assistant, response) })); } }; - ClassificationEnricher sut = new(chatClient, ["AI", "Animals", "Sports"], fallbackClass: "UFO"); + ClassificationEnricher sut = new(new(chatClient), ["AI", "Animals", "Sports"], fallbackClass: "UFO"); IReadOnlyList> got = await sut.ProcessAsync(CreateChunks().ToAsyncEnumerable()).ToListAsync(); @@ -97,29 +96,25 @@ public async Task CanClassify() } [Fact] - public async Task ThrowsOnInvalidResponse() + public async Task FailureDoesNotStopTheProcessing() { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); using TestChatClient chatClient = new() { - GetResponseAsyncCallback = (messages, options, cancellationToken) => - { - return Task.FromResult(new ChatResponse(new[] - { - new ChatMessage(ChatRole.Assistant, "Unexpected result!") - })); - } + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) }; - ClassificationEnricher sut = new(chatClient, ["AI", "Animals", "Sports"]); - var input = CreateChunks().ToAsyncEnumerable(); + ClassificationEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }, ["AI", "Other"]); + List> chunks = CreateChunks(); - await Assert.ThrowsAsync(async () => - { - await foreach (var _ in sut.ProcessAsync(input)) - { - // No-op - } - }); + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); } private static List> CreateChunks() => diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/KeywordEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/KeywordEnricherTests.cs index 0f11cd7d46b..5a116e1ab04 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/KeywordEnricherTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/KeywordEnricherTests.cs @@ -4,8 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Xunit; namespace Microsoft.Extensions.DataIngestion.Processors.Tests; @@ -15,9 +18,9 @@ public class KeywordEnricherTests private static readonly IngestionDocument _document = new("test"); [Fact] - public void ThrowsOnNullChatClient() + public void ThrowsOnNullOptions() { - Assert.Throws("chatClient", () => new KeywordEnricher(null!, predefinedKeywords: null, confidenceThreshold: 0.5)); + Assert.Throws("options", () => new KeywordEnricher(null!, predefinedKeywords: null, confidenceThreshold: 0.5)); } [Theory] @@ -25,7 +28,7 @@ public void ThrowsOnNullChatClient() [InlineData(1.1)] public void ThrowsOnInvalidThreshold(double threshold) { - Assert.Throws("confidenceThreshold", () => new KeywordEnricher(new TestChatClient(), predefinedKeywords: null, confidenceThreshold: threshold)); + Assert.Throws("confidenceThreshold", () => new KeywordEnricher(new(new TestChatClient()), predefinedKeywords: null, confidenceThreshold: threshold)); } [Theory] @@ -33,28 +36,20 @@ public void ThrowsOnInvalidThreshold(double threshold) [InlineData(-1)] public void ThrowsOnInvalidMaxKeywords(int keywordCount) { - Assert.Throws("maxKeywords", () => new KeywordEnricher(new TestChatClient(), predefinedKeywords: null, maxKeywords: keywordCount)); + Assert.Throws("maxKeywords", () => new KeywordEnricher(new(new TestChatClient()), predefinedKeywords: null, maxKeywords: keywordCount)); } [Fact] public void ThrowsOnDuplicateKeywords() { - Assert.Throws("predefinedKeywords", () => new KeywordEnricher(new TestChatClient(), predefinedKeywords: ["same", "same"], confidenceThreshold: 0.5)); - } - - [Theory] - [InlineData(',')] - [InlineData(';')] - public void ThrowsOnIllegalCharacters(char illegal) - { - Assert.Throws("predefinedKeywords", () => new KeywordEnricher(new TestChatClient(), predefinedKeywords: [$"n{illegal}t"])); + Assert.Throws("predefinedKeywords", () => new KeywordEnricher(new(new TestChatClient()), predefinedKeywords: ["same", "same"], confidenceThreshold: 0.5)); } [Fact] public async Task ThrowsOnNullChunks() { using TestChatClient chatClient = new(); - KeywordEnricher sut = new(chatClient, predefinedKeywords: null, confidenceThreshold: 0.5); + KeywordEnricher sut = new(new(chatClient), predefinedKeywords: null, confidenceThreshold: 0.5); await Assert.ThrowsAsync("chunks", async () => { @@ -71,25 +66,27 @@ await Assert.ThrowsAsync("chunks", async () => public async Task CanExtractKeywords(params string[] predefined) { int counter = 0; - string[] keywords = { "AI;MEAI", "Animals;Rabbits" }; + string[][] keywords = [["AI", "MEAI"], ["Animals", "Rabbits"]]; using TestChatClient chatClient = new() { GetResponseAsyncCallback = (messages, options, cancellationToken) => { + Assert.Equal(0, counter++); var materializedMessages = messages.ToArray(); Assert.Equal(2, materializedMessages.Length); Assert.Equal(ChatRole.System, materializedMessages[0].Role); Assert.Equal(ChatRole.User, materializedMessages[1].Role); + string response = JsonSerializer.Serialize(new Envelope { data = keywords }); return Task.FromResult(new ChatResponse(new[] { - new ChatMessage(ChatRole.Assistant, keywords[counter++]) + new ChatMessage(ChatRole.Assistant, response) })); } }; - KeywordEnricher sut = new(chatClient, predefinedKeywords: predefined, confidenceThreshold: 0.5); + KeywordEnricher sut = new(new(chatClient), predefinedKeywords: predefined, confidenceThreshold: 0.5); var chunks = CreateChunks().ToAsyncEnumerable(); IReadOnlyList> got = await sut.ProcessAsync(chunks).ToListAsync(); @@ -99,29 +96,25 @@ public async Task CanExtractKeywords(params string[] predefined) } [Fact] - public async Task ThrowsOnInvalidResponse() + public async Task FailureDoesNotStopTheProcessing() { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); using TestChatClient chatClient = new() { - GetResponseAsyncCallback = (messages, options, cancellationToken) => - { - return Task.FromResult(new ChatResponse(new[] - { - new ChatMessage(ChatRole.Assistant, "Unexpected result!") - })); - } + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) }; - KeywordEnricher sut = new(chatClient, ["some"]); - var input = CreateChunks().ToAsyncEnumerable(); + KeywordEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }, ["AI", "Other"]); + List> chunks = CreateChunks(); - await Assert.ThrowsAsync(async () => - { - await foreach (var _ in sut.ProcessAsync(input)) - { - // No-op - } - }); + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); } private static List> CreateChunks() => diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SentimentEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SentimentEnricherTests.cs index 166b3c05959..8d762f3199c 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SentimentEnricherTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SentimentEnricherTests.cs @@ -4,8 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Xunit; namespace Microsoft.Extensions.DataIngestion.Processors.Tests; @@ -15,9 +18,9 @@ public class SentimentEnricherTests private static readonly IngestionDocument _document = new("test"); [Fact] - public void ThrowsOnNullChatClient() + public void ThrowsOnNullOptions() { - Assert.Throws("chatClient", () => new SentimentEnricher(null!)); + Assert.Throws("options", () => new SentimentEnricher(null!)); } [Theory] @@ -25,14 +28,14 @@ public void ThrowsOnNullChatClient() [InlineData(1.1)] public void ThrowsOnInvalidThreshold(double threshold) { - Assert.Throws("confidenceThreshold", () => new SentimentEnricher(new TestChatClient(), confidenceThreshold: threshold)); + Assert.Throws("confidenceThreshold", () => new SentimentEnricher(new(new TestChatClient()), confidenceThreshold: threshold)); } [Fact] public async Task ThrowsOnNullChunks() { using TestChatClient chatClient = new(); - SentimentEnricher sut = new(chatClient); + SentimentEnricher sut = new(new(chatClient)); await Assert.ThrowsAsync("chunks", async () => { @@ -52,19 +55,21 @@ public async Task CanProvideSentiment() { GetResponseAsyncCallback = (messages, options, cancellationToken) => { + Assert.Equal(0, counter++); var materializedMessages = messages.ToArray(); Assert.Equal(2, materializedMessages.Length); Assert.Equal(ChatRole.System, materializedMessages[0].Role); Assert.Equal(ChatRole.User, materializedMessages[1].Role); + string response = JsonSerializer.Serialize(new Envelope { data = sentiments }); return Task.FromResult(new ChatResponse(new[] { - new ChatMessage(ChatRole.Assistant, sentiments[counter++]) + new ChatMessage(ChatRole.Assistant, response) })); } }; - SentimentEnricher sut = new(chatClient); + SentimentEnricher sut = new(new(chatClient)); var input = CreateChunks().ToAsyncEnumerable(); var chunks = await sut.ProcessAsync(input).ToListAsync(); @@ -78,29 +83,25 @@ public async Task CanProvideSentiment() } [Fact] - public async Task ThrowsOnInvalidResponse() + public async Task FailureDoesNotStopTheProcessing() { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); using TestChatClient chatClient = new() { - GetResponseAsyncCallback = (messages, options, cancellationToken) => - { - return Task.FromResult(new ChatResponse(new[] - { - new ChatMessage(ChatRole.Assistant, "Unexpected result!") - })); - } + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) }; - SentimentEnricher sut = new(chatClient); - var input = CreateChunks().ToAsyncEnumerable(); + SentimentEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }); + List> chunks = CreateChunks(); - await Assert.ThrowsAsync(async () => - { - await foreach (var _ in sut.ProcessAsync(input)) - { - // No-op - } - }); + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); } private static List> CreateChunks() => diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SummaryEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SummaryEnricherTests.cs index 6fda37004d3..8b0dcd904c4 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SummaryEnricherTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SummaryEnricherTests.cs @@ -4,8 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Xunit; namespace Microsoft.Extensions.DataIngestion.Processors.Tests; @@ -15,9 +18,9 @@ public class SummaryEnricherTests private static readonly IngestionDocument _document = new("test"); [Fact] - public void ThrowsOnNullChatClient() + public void ThrowsOnNullOptions() { - Assert.Throws("chatClient", () => new SummaryEnricher(null!)); + Assert.Throws("options", () => new SummaryEnricher(null!)); } [Theory] @@ -25,14 +28,14 @@ public void ThrowsOnNullChatClient() [InlineData(-1)] public void ThrowsOnInvalidMaxKeywords(int wordCount) { - Assert.Throws("maxWordCount", () => new SummaryEnricher(new TestChatClient(), maxWordCount: wordCount)); + Assert.Throws("maxWordCount", () => new SummaryEnricher(new(new TestChatClient()), maxWordCount: wordCount)); } [Fact] public async Task ThrowsOnNullChunks() { using TestChatClient chatClient = new(); - SummaryEnricher sut = new(chatClient); + SummaryEnricher sut = new(new(chatClient)); await Assert.ThrowsAsync("chunks", async () => { @@ -52,19 +55,21 @@ public async Task CanProvideSummary() { GetResponseAsyncCallback = (messages, options, cancellationToken) => { + Assert.Equal(0, counter++); var materializedMessages = messages.ToArray(); Assert.Equal(2, materializedMessages.Length); Assert.Equal(ChatRole.System, materializedMessages[0].Role); Assert.Equal(ChatRole.User, materializedMessages[1].Role); + string response = JsonSerializer.Serialize(new Envelope { data = summaries }); return Task.FromResult(new ChatResponse(new[] { - new ChatMessage(ChatRole.Assistant, summaries[counter++]) + new ChatMessage(ChatRole.Assistant, response) })); } }; - SummaryEnricher sut = new(chatClient); + SummaryEnricher sut = new(new(chatClient)); var input = CreateChunks().ToAsyncEnumerable(); var chunks = await sut.ProcessAsync(input).ToListAsync(); @@ -74,6 +79,28 @@ public async Task CanProvideSummary() Assert.Equal(summaries[1], (string)chunks[1].Metadata[SummaryEnricher.MetadataKey]!); } + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + SummaryEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }); + List> chunks = CreateChunks(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); + } + private static List> CreateChunks() => [ new("I love programming! It's so much fun and rewarding.", _document), diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownMcpReaderTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownMcpReaderTests.cs new file mode 100644 index 00000000000..37142f8b20e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownMcpReaderTests.cs @@ -0,0 +1,53 @@ +// 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.IO; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +public class MarkItDownMcpReaderTests +{ + [Fact] + public void Constructor_ThrowsWhenMcpServerUriIsNull() + { + Assert.Throws("mcpServerUri", () => new MarkItDownMcpReader(null!)); + } + + [Fact] + public async Task ReadAsync_ThrowsWhenIdentifierIsNull() + { + var reader = new MarkItDownMcpReader(new Uri("http://localhost:3001/sse")); + + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(new FileInfo("fileName.txt"), identifier: null!)); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(new FileInfo("fileName.txt"), identifier: string.Empty)); + + using MemoryStream stream = new(); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(stream, identifier: null!, mediaType: "some")); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(stream, identifier: string.Empty, mediaType: "some")); + } + + [Fact] + public async Task ReadAsync_ThrowsWhenSourceIsNull() + { + var reader = new MarkItDownMcpReader(new Uri("http://localhost:3001/sse")); + + await Assert.ThrowsAsync("source", async () => await reader.ReadAsync(null!, "identifier")); + await Assert.ThrowsAsync("source", async () => await reader.ReadAsync((Stream)null!, "identifier", "mediaType")); + } + + [Fact] + public async Task ReadAsync_ThrowsWhenFileDoesNotExist() + { + var reader = new MarkItDownMcpReader(new Uri("http://localhost:3001/sse")); + var nonExistentFile = new FileInfo(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())); + + await Assert.ThrowsAsync(async () => await reader.ReadAsync(nonExistentFile, "identifier")); + } + + // NOTE: Integration tests with an actual MCP server would go here, but they would require + // a running MarkItDown MCP server to be available, which is not part of the test setup. + // For full integration testing, use a real MCP server in a separate test environment. +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/Envelope{T}.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/Envelope{T}.cs new file mode 100644 index 00000000000..d6fade6892f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/Envelope{T}.cs @@ -0,0 +1,11 @@ +// 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.DataIngestion; + +internal class Envelope +{ +#pragma warning disable IDE1006 // Naming Styles + public T? data { get; set; } +#pragma warning restore IDE1006 // Naming Styles +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ExpectedException.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ExpectedException.cs new file mode 100644 index 00000000000..79d2e7538fd --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ExpectedException.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.DataIngestion; + +internal sealed class ExpectedException : Exception +{ + internal const string ExceptionMessage = "An expected exception occurred."; + + internal ExpectedException() + : base(ExceptionMessage) + { + } +} diff --git a/test/ProjectTemplates/.gitignore b/test/ProjectTemplates/.gitignore new file mode 100644 index 00000000000..6fbeae3c400 --- /dev/null +++ b/test/ProjectTemplates/.gitignore @@ -0,0 +1 @@ +*/ExecutionTestSandbox diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/DotNetCommand.cs b/test/ProjectTemplates/Infrastructure/DotNetCommand.cs similarity index 89% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/DotNetCommand.cs rename to test/ProjectTemplates/Infrastructure/DotNetCommand.cs index 4758e14dc1f..df6ed848c3e 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/DotNetCommand.cs +++ b/test/ProjectTemplates/Infrastructure/DotNetCommand.cs @@ -3,7 +3,7 @@ using System; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; public class DotNetCommand : TestCommand { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/DotNetNewCommand.cs b/test/ProjectTemplates/Infrastructure/DotNetNewCommand.cs similarity index 95% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/DotNetNewCommand.cs rename to test/ProjectTemplates/Infrastructure/DotNetNewCommand.cs index cdd6ab73f03..e013d0d4e4e 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/DotNetNewCommand.cs +++ b/test/ProjectTemplates/Infrastructure/DotNetNewCommand.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using Xunit.Abstractions; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; public sealed class DotNetNewCommand : DotNetCommand { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/ITemplateExecutionTestConfigurationProvider.cs b/test/ProjectTemplates/Infrastructure/ITemplateExecutionTestConfigurationProvider.cs similarity index 84% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/ITemplateExecutionTestConfigurationProvider.cs rename to test/ProjectTemplates/Infrastructure/ITemplateExecutionTestConfigurationProvider.cs index 3a499013495..5a05ff8e32a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/ITemplateExecutionTestConfigurationProvider.cs +++ b/test/ProjectTemplates/Infrastructure/ITemplateExecutionTestConfigurationProvider.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; public interface ITemplateExecutionTestConfigurationProvider { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/MessageSinkTestOutputHelper.cs b/test/ProjectTemplates/Infrastructure/MessageSinkTestOutputHelper.cs similarity index 93% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/MessageSinkTestOutputHelper.cs rename to test/ProjectTemplates/Infrastructure/MessageSinkTestOutputHelper.cs index d81c1f7c434..6118a0ad4e2 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/MessageSinkTestOutputHelper.cs +++ b/test/ProjectTemplates/Infrastructure/MessageSinkTestOutputHelper.cs @@ -4,7 +4,7 @@ using Xunit.Abstractions; using Xunit.Sdk; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; public sealed class MessageSinkTestOutputHelper : ITestOutputHelper { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/ProcessExtensions.cs b/test/ProjectTemplates/Infrastructure/ProcessExtensions.cs similarity index 84% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/ProcessExtensions.cs rename to test/ProjectTemplates/Infrastructure/ProcessExtensions.cs index a20d390794d..cbedf75e354 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/ProcessExtensions.cs +++ b/test/ProjectTemplates/Infrastructure/ProcessExtensions.cs @@ -1,7 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace System.Diagnostics; +using System; +using System.Diagnostics; + +namespace Microsoft.Shared.ProjectTemplates.Tests; public static class ProcessExtensions { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs b/test/ProjectTemplates/Infrastructure/Project.cs similarity index 94% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs rename to test/ProjectTemplates/Infrastructure/Project.cs index 317e81a661f..129866f3fef 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs +++ b/test/ProjectTemplates/Infrastructure/Project.cs @@ -4,7 +4,7 @@ using System; using System.IO; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; public sealed class Project(string rootPath, string name) { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestBase.cs b/test/ProjectTemplates/Infrastructure/TemplateExecutionTestBase.cs similarity index 97% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestBase.cs rename to test/ProjectTemplates/Infrastructure/TemplateExecutionTestBase.cs index b52e8cda3a6..430f731e0c8 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestBase.cs +++ b/test/ProjectTemplates/Infrastructure/TemplateExecutionTestBase.cs @@ -5,7 +5,7 @@ using Xunit; using Xunit.Abstractions; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; /// /// Represents a test that executes a project template (create, restore, build, and run). diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestClassFixtureBase.cs b/test/ProjectTemplates/Infrastructure/TemplateExecutionTestClassFixtureBase.cs similarity index 87% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestClassFixtureBase.cs rename to test/ProjectTemplates/Infrastructure/TemplateExecutionTestClassFixtureBase.cs index 3592fd6474b..9144593db73 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestClassFixtureBase.cs +++ b/test/ProjectTemplates/Infrastructure/TemplateExecutionTestClassFixtureBase.cs @@ -7,7 +7,7 @@ using Xunit; using Xunit.Abstractions; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; /// /// Provides functionality scoped to the duration of all the tests in a single test class @@ -61,7 +61,8 @@ async Task InstallTemplatesAsync() .WithEnvironmentVariable("NUGET_PACKAGES", WellKnownPaths.NuGetPackagesPath) .WithCustomHive(_customHivePath) .ExecuteAsync(OutputHelper); - installResult.AssertSucceeded(); + + installResult.AssertSucceeded($"dotnet new install {_configuration.TemplatePackageName}"); } } @@ -78,11 +79,14 @@ public async Task CreateProjectAsync(string templateName, string projec .. args ]; + var testDescription = string.Join(' ', dotNetNewCommandArgs); + var newProjectResult = await new DotNetNewCommand(dotNetNewCommandArgs) .WithWorkingDirectory(_templateTestOutputPath) .WithCustomHive(_customHivePath) .ExecuteAsync(OutputHelper); - newProjectResult.AssertSucceeded(); + + newProjectResult.AssertSucceeded(testDescription); var templateNuGetConfigPath = Path.Combine(outputFolderPath, "nuget.config"); File.Copy(WellKnownPaths.TemplateTestNuGetConfigPath, templateNuGetConfigPath); @@ -97,7 +101,14 @@ public async Task RestoreProjectAsync(Project project) .WithEnvironmentVariable("LOCAL_SHIPPING_PATH", WellKnownPaths.LocalShippingPackagesPath) .WithEnvironmentVariable("NUGET_PACKAGES", WellKnownPaths.NuGetPackagesPath) .ExecuteAsync(OutputHelper); - restoreResult.AssertSucceeded(); + + restoreResult.AssertSucceeded($""" + dotnet restore + + Working Directory: {project.StartupProjectFullPath} + Local Shipping Path: {WellKnownPaths.LocalShippingPackagesPath} + NuGet Packages Path: {WellKnownPaths.NuGetPackagesPath} + """); } public async Task BuildProjectAsync(Project project) @@ -105,7 +116,12 @@ public async Task BuildProjectAsync(Project project) var buildResult = await new DotNetCommand("build", "--no-restore") .WithWorkingDirectory(project.StartupProjectFullPath) .ExecuteAsync(OutputHelper); - buildResult.AssertSucceeded(); + + buildResult.AssertSucceeded($""" + dotnet build --no-restore + + Working Directory: {project.StartupProjectFullPath} + """); } public void SetCurrentTestOutputHelper(ITestOutputHelper? outputHelper) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestCollection.cs b/test/ProjectTemplates/Infrastructure/TemplateExecutionTestCollection.cs similarity index 87% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestCollection.cs rename to test/ProjectTemplates/Infrastructure/TemplateExecutionTestCollection.cs index 9f10ffdf974..dfabfb9a089 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestCollection.cs +++ b/test/ProjectTemplates/Infrastructure/TemplateExecutionTestCollection.cs @@ -3,7 +3,7 @@ using Xunit; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; [CollectionDefinition(name: Name)] public sealed class TemplateExecutionTestCollection : ICollectionFixture diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestCollectionFixture.cs b/test/ProjectTemplates/Infrastructure/TemplateExecutionTestCollectionFixture.cs similarity index 68% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestCollectionFixture.cs rename to test/ProjectTemplates/Infrastructure/TemplateExecutionTestCollectionFixture.cs index 13140b0599e..3b015d80202 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestCollectionFixture.cs +++ b/test/ProjectTemplates/Infrastructure/TemplateExecutionTestCollectionFixture.cs @@ -3,7 +3,7 @@ using System.IO; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; /// /// Provides functionality scoped to the lifetime of all tests defined in @@ -25,5 +25,15 @@ public TemplateExecutionTestCollectionFixture() { Directory.Delete(WellKnownPaths.TemplateSandboxOutputRoot, recursive: true); } + + // Then we copy the template sandbox infrastructure to the output location for use during tests. + Directory.CreateDirectory(WellKnownPaths.TemplateSandboxOutputRoot); + + foreach (var filePath in Directory.EnumerateFiles(WellKnownPaths.TemplateSandboxSource)) + { + var fileName = Path.GetFileName(filePath); + var destFilePath = Path.Combine(WellKnownPaths.TemplateSandboxOutputRoot, fileName); + File.Copy(filePath, destFilePath); + } } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestConfiguration.cs b/test/ProjectTemplates/Infrastructure/TemplateExecutionTestConfiguration.cs similarity index 86% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestConfiguration.cs rename to test/ProjectTemplates/Infrastructure/TemplateExecutionTestConfiguration.cs index ce621e58528..060f28fe368 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TemplateExecutionTestConfiguration.cs +++ b/test/ProjectTemplates/Infrastructure/TemplateExecutionTestConfiguration.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; public sealed class TemplateExecutionTestConfiguration { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/.editorconfig b/test/ProjectTemplates/Infrastructure/TemplateSandbox/.editorconfig similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/.editorconfig rename to test/ProjectTemplates/Infrastructure/TemplateSandbox/.editorconfig diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/Directory.Build.props b/test/ProjectTemplates/Infrastructure/TemplateSandbox/Directory.Build.props similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/Directory.Build.props rename to test/ProjectTemplates/Infrastructure/TemplateSandbox/Directory.Build.props diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/Directory.Build.targets b/test/ProjectTemplates/Infrastructure/TemplateSandbox/Directory.Build.targets similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/Directory.Build.targets rename to test/ProjectTemplates/Infrastructure/TemplateSandbox/Directory.Build.targets diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/README.md b/test/ProjectTemplates/Infrastructure/TemplateSandbox/README.md similarity index 79% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/README.md rename to test/ProjectTemplates/Infrastructure/TemplateSandbox/README.md index 7db29aaab19..6a9ecb84b86 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/README.md +++ b/test/ProjectTemplates/Infrastructure/TemplateSandbox/README.md @@ -17,15 +17,15 @@ Template tests can be debugged either in VS or by running `dotnet test`. However, it's sometimes helpful to debug failures by building, running, and modifying the generated projects directly instead of tinkering with test code. To help with this scenario: -* The `output/` folder containing the generated projects doesn't get cleared until the start of the next test run. +* The `ExecutionTestSandbox` folder containing the generated projects doesn't get cleared until the start of the next test run. * An `activate.ps1` script can be used to simulate the environment that the template execution tests use. This script: * Sets the active .NET installation to `/.dotnet`. - * Sets the `NUGET_PACKAGES` environment variable to the `output/packages` folder to use the isolated package cache. + * Sets the `NUGET_PACKAGES` environment variable to the `ExecutionTestSandbox/packages` folder to use the isolated package cache. * Sets a `LOCAL_SHIPPING_PATH` environment variable so that locally-built packages can get picked up during restore. As an example, here's how you can build a project generated by the tests: ```sh . ./activate.ps1 -cd ./output/[test_collection]/[generated_template] +cd ./[test_collection]/[generated_template] dotnet build ``` diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/activate.ps1 b/test/ProjectTemplates/Infrastructure/TemplateSandbox/activate.ps1 similarity index 98% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/activate.ps1 rename to test/ProjectTemplates/Infrastructure/TemplateSandbox/activate.ps1 index a3dea765dd5..43773c3fa19 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/activate.ps1 +++ b/test/ProjectTemplates/Infrastructure/TemplateSandbox/activate.ps1 @@ -84,7 +84,7 @@ $env:DOTNET_MULTILEVEL_LOOKUP = 0 # Put dotnet first on PATH $env:PATH = "${env:DOTNET_ROOT};${env:PATH}" # Set NUGET_PACKAGES and LOCAL_SHIPPING_PATH -$env:NUGET_PACKAGES = "$PSScriptRoot\output\packages" +$env:NUGET_PACKAGES = "$PSScriptRoot\packages" $env:LOCAL_SHIPPING_PATH = "$repoRoot\artifacts\packages\$configuration\Shipping\" # Set the shell prompt diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/nuget.template_install.config b/test/ProjectTemplates/Infrastructure/TemplateSandbox/nuget.template_install.config similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/nuget.template_install.config rename to test/ProjectTemplates/Infrastructure/TemplateSandbox/nuget.template_install.config diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/nuget.template_test.config b/test/ProjectTemplates/Infrastructure/TemplateSandbox/nuget.template_test.config similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/TemplateSandbox/nuget.template_test.config rename to test/ProjectTemplates/Infrastructure/TemplateSandbox/nuget.template_test.config diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommand.cs b/test/ProjectTemplates/Infrastructure/TestCommand.cs similarity index 98% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommand.cs rename to test/ProjectTemplates/Infrastructure/TestCommand.cs index 697bf009f9c..dfe89030cad 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommand.cs +++ b/test/ProjectTemplates/Infrastructure/TestCommand.cs @@ -9,7 +9,7 @@ using System.Threading.Tasks; using Xunit.Abstractions; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; public abstract class TestCommand { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandExtensions.cs b/test/ProjectTemplates/Infrastructure/TestCommandExtensions.cs similarity index 94% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandExtensions.cs rename to test/ProjectTemplates/Infrastructure/TestCommandExtensions.cs index 957c0efbb79..bc8f17dd89a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandExtensions.cs +++ b/test/ProjectTemplates/Infrastructure/TestCommandExtensions.cs @@ -3,7 +3,7 @@ using System; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; public static class TestCommandExtensions { diff --git a/test/ProjectTemplates/Infrastructure/TestCommandResult.cs b/test/ProjectTemplates/Infrastructure/TestCommandResult.cs new file mode 100644 index 00000000000..fd4c5a0b547 --- /dev/null +++ b/test/ProjectTemplates/Infrastructure/TestCommandResult.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Xunit; + +namespace Microsoft.Shared.ProjectTemplates.Tests; + +public sealed class TestCommandResult(StringBuilder standardOutputBuilder, StringBuilder standardErrorBuilder, int exitCode) +{ + public string StandardOutput => field ??= standardOutputBuilder.ToString(); + + public string StandardError => field ??= standardErrorBuilder.ToString(); + + public int ExitCode => exitCode; + + public void AssertSucceeded(string testDescription) + { + var output = $""" + {testDescription} + + {(ExitCode != 0 ? $""" + Command failed with non-zero exit code: {ExitCode} + + """ : string.Empty)} + {(!string.IsNullOrWhiteSpace(StandardOutput) ? + $""" + >> Standard Output: + {StandardOutput} + + """ : string.Empty)} + {(!string.IsNullOrWhiteSpace(StandardError) ? + $""" + >>> Standard Error: + {StandardError} + + """ : string.Empty)} + """; + + Assert.True(ExitCode == 0 && string.IsNullOrWhiteSpace(StandardError), output); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/VerifyScrubbers.cs b/test/ProjectTemplates/Infrastructure/VerifyScrubbers.cs similarity index 97% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/VerifyScrubbers.cs rename to test/ProjectTemplates/Infrastructure/VerifyScrubbers.cs index 75f42ba64ea..505feb99c14 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/VerifyScrubbers.cs +++ b/test/ProjectTemplates/Infrastructure/VerifyScrubbers.cs @@ -4,7 +4,7 @@ using System.Text; using System.Text.RegularExpressions; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; internal static class VerifyScrubbers { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/WellKnownPaths.cs b/test/ProjectTemplates/Infrastructure/WellKnownPaths.cs similarity index 86% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/WellKnownPaths.cs rename to test/ProjectTemplates/Infrastructure/WellKnownPaths.cs index 0d399dfcfe7..0ebb196fe84 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/WellKnownPaths.cs +++ b/test/ProjectTemplates/Infrastructure/WellKnownPaths.cs @@ -5,7 +5,7 @@ using System.IO; using System.Runtime.InteropServices; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; internal static class WellKnownPaths { @@ -14,7 +14,7 @@ internal static class WellKnownPaths public static readonly string ThisProjectRoot; public static readonly string TemplateFeedLocation; - public static readonly string TemplateSandboxRoot; + public static readonly string TemplateSandboxSource; public static readonly string TemplateSandboxOutputRoot; public static readonly string TemplateInstallNuGetConfigPath; public static readonly string TemplateTestNuGetConfigPath; @@ -28,10 +28,10 @@ static WellKnownPaths() ThisProjectRoot = ProjectRootHelper.GetThisProjectRoot(); TemplateFeedLocation = Path.Combine(RepoRoot, "src", "ProjectTemplates"); - TemplateSandboxRoot = Path.Combine(ThisProjectRoot, "TemplateSandbox"); - TemplateSandboxOutputRoot = Path.Combine(TemplateSandboxRoot, "output"); - TemplateInstallNuGetConfigPath = Path.Combine(TemplateSandboxRoot, "nuget.template_install.config"); - TemplateTestNuGetConfigPath = Path.Combine(TemplateSandboxRoot, "nuget.template_test.config"); + TemplateSandboxSource = Path.Combine(RepoRoot, "test", "ProjectTemplates", "Infrastructure", "TemplateSandbox"); + TemplateSandboxOutputRoot = Path.Combine(ThisProjectRoot, "ExecutionTestSandbox"); + TemplateInstallNuGetConfigPath = Path.Combine(TemplateSandboxSource, "nuget.template_install.config"); + TemplateTestNuGetConfigPath = Path.Combine(TemplateSandboxSource, "nuget.template_test.config"); const string BuildConfigurationFolder = #if DEBUG diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Microsoft.Agents.AI.ProjectTemplates.Tests.csproj b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Microsoft.Agents.AI.ProjectTemplates.Tests.csproj new file mode 100644 index 00000000000..1c529dff3f3 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Microsoft.Agents.AI.ProjectTemplates.Tests.csproj @@ -0,0 +1,33 @@ + + + + Tests for Microsoft.Agents.AI.ProjectTemplates. + false + true + + + + $(NoWarn);CA1063;CA1716;CA1861;CA2201;VSTHRD003;S104;S125;S2699 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/ProjectRootHelper.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/ProjectRootHelper.cs new file mode 100644 index 00000000000..baca09bf468 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/ProjectRootHelper.cs @@ -0,0 +1,35 @@ +// 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.IO; +using System.Runtime.CompilerServices; + +namespace Microsoft.Shared.ProjectTemplates.Tests; + +/// +/// Contains a helper for determining the disk location of the containing project folder. +/// +/// +/// It's important that this file resides in the root of the containing project, or the returned +/// project root path will be incorrect. +/// +internal static class ProjectRootHelper +{ + public static string GetThisProjectRoot() + => GetThisProjectRootCore(); + + // This helper method is defined separately from its public variant because it extracts the + // caller file path via the [CallerFilePath] attribute. + // Therefore, the caller must be in a known location, i.e., this source file, to produce + // a reliable result. + private static string GetThisProjectRootCore([CallerFilePath] string callerFilePath = "") + { + if (Path.GetDirectoryName(callerFilePath) is not { Length: > 0 } testProjectRoot) + { + throw new InvalidOperationException("Could not determine the root of the test project."); + } + + return testProjectRoot; + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/README.md new file mode 100644 index 00000000000..259022bfa73 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/README.md @@ -0,0 +1,7 @@ +# Microsoft.Agents.AI.ProjectTemplates tests + +Contains snapshot and execution tests for `Microsoft.Agents.AI.ProjectTemplates`. + +To update test snapshots, install and run the `DiffEngineTray` tool following [these instructions](https://github.com/VerifyTests/DiffEngine/blob/main/docs/tray.md), run the snapshot tests either in VS or using `dotnet test`, and use `DiffEngineTray` to accept or discard changes. + +For information on debugging template execution tests, see [this README](./TemplateSandbox/README.md). diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/README.md new file mode 100644 index 00000000000..99247d4574c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/README.md @@ -0,0 +1,5 @@ +# Snapshots + +This directory contains verified snapshots of generated template output. These snapshots are used by the template snapshot tests to ensure templates generate expected output. + +When template content is added, this directory will contain subdirectories for each test scenario. diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/Program.cs new file mode 100644 index 00000000000..ddc551393fa --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/Program.cs @@ -0,0 +1,67 @@ +using System.ClientModel; +using System.ComponentModel; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DevUI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Chat; + +var builder = WebApplication.CreateBuilder(args); + +// You will need to set the endpoint to your own value +// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: +// cd this-project-directory +// dotnet user-secrets set AzureOpenAI:Endpoint https://YOUR-DEPLOYMENT-NAME.openai.azure.com +// dotnet user-secrets set AzureOpenAI:Key YOUR-API-KEY +var azureOpenAIEndpoint = new Uri(new Uri(builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAI:Endpoint")), "/openai/v1"); + +var chatClient = new ChatClient( + "gpt-4o-mini", + new ApiKeyCredential(builder.Configuration["AzureOpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAI:Key")), + new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }) + .AsIChatClient(); + +builder.Services.AddChatClient(chatClient); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] +)); + +builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( + workflowName: key, + sp.GetRequiredKeyedService("writer"), + sp.GetRequiredKeyedService("editor") +)).AddAsAIAgent(); + +// Register services for OpenAI responses and conversations (also required for DevUI) +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); +app.UseHttpsRedirection(); + +// Map endpoints for OpenAI responses and conversations (also required for DevUI) +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +if (builder.Environment.IsDevelopment()) +{ + // Map DevUI endpoint to /devui + app.MapDevUI(); +} + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/Properties/launchSettings.json new file mode 100644 index 00000000000..c242aacc025 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "https://localhost:9999;http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/README.md new file mode 100644 index 00000000000..de250a2916e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/README.md @@ -0,0 +1,128 @@ +# AI Agent Web API + +This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). + +## Prerequisites + +- An Azure OpenAI service deployment + +## Getting Started + +### 1. Configure Your AI Service + +#### Azure OpenAI Configuration + + +**Using User Secrets (Recommended for Development)** + +```bash +dotnet user-secrets set "AzureOpenAI:Endpoint" "https://YOUR-DEPLOYMENT-NAME.openai.azure.com" +dotnet user-secrets set "AzureOpenAI:Key" "your-azure-openai-key-here" +``` + +**Using Environment Variables** + +- **Windows (PowerShell)**: + ```powershell + $env:AzureOpenAI__Endpoint = "https://YOUR-DEPLOYMENT-NAME.openai.azure.com" + $env:AzureOpenAI__Key = "your-azure-openai-key-here" + ``` + +- **Linux/macOS**: + ```bash + export AzureOpenAI__Endpoint="https://YOUR-DEPLOYMENT-NAME.openai.azure.com" + export AzureOpenAI__Key="your-azure-openai-key-here" + ``` + +#### Set Up Azure OpenAI + +1. Visit [Azure Portal](https://portal.azure.com) +2. Create an Azure OpenAI resource +3. Deploy a model (e.g., gpt-4o-mini) + + +### 2. Run the Application + +```bash +dotnet run -lp https +``` + +The application will start and listen on: +- HTTP: `http://localhost:9999` +- HTTPS: `https://localhost:9999` + +### 3. Test the Application + +The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. + +In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. + +## How It Works + +This application demonstrates Agent Framework with: + +1. **Writer Agent**: Writes short stories (300 words or less) about specified topics +2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words +3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents + +The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. + +## Template Parameters + +When creating a new project, you can customize it using template parameters: + +```bash +# Specify AI service provider +dotnet new aiagent-webapi --provider azureopenai + +# Specify a custom chat model +dotnet new aiagent-webapi --chat-model gpt-4o + +# Use API key authentication for Azure OpenAI +dotnet new aiagent-webapi --provider azureopenai --managed-identity false + +# Use Ollama with a different model +dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 +``` + +### Available Parameters + +- **`--provider`**: Choose the AI service provider + - `githubmodels` (default) - GitHub Models + - `azureopenai` - Azure OpenAI + - `openai` - OpenAI Platform + - `ollama` - Ollama (local development) + +- **`--chat-model`**: Specify the chat model/deployment name + - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for Ollama: `llama3.2` + +- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) + - Only applicable when `--provider azureopenai` + +- **`--framework`**: Target framework (default: `net10.0`) + - Options: `net10.0`, `net9.0`, `net8.0` + +## Project Structure + +- `Program.cs` - Application entry point and configuration +- `appsettings.json` - Application configuration +- `Properties/launchSettings.json` - Launch profiles for development + +## Learn More + +- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) +- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) +- [Azure OpenAI Service](https://azure.microsoft.com/products/ai-services/openai-service) + +## Troubleshooting + +**Problem**: Application fails with "Missing configuration: AzureOpenAI:Endpoint" or "Missing configuration: AzureOpenAI:Key" + +**Solution**: Make sure you've configured your Azure OpenAI endpoint and API key using one of the methods described above. + +**Problem**: API requests fail with authentication errors + +**Solution**: Verify your Azure OpenAI endpoint is correct and your API key is valid. + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/aiagent-webapi.csproj b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/aiagent-webapi.csproj new file mode 100644 index 00000000000..337bfdd220c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/aiagent-webapi.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + secret + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/appsettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/appsettings.json new file mode 100644 index 00000000000..223027717b4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ApiKey.verified/aiagent-webapi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/Program.cs new file mode 100644 index 00000000000..3749e820b45 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/Program.cs @@ -0,0 +1,69 @@ +using System.ClientModel.Primitives; +using System.ComponentModel; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DevUI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Chat; + +var builder = WebApplication.CreateBuilder(args); + +// You will need to set the endpoint to your own value +// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: +// cd this-project-directory +// dotnet user-secrets set AzureOpenAI:Endpoint https://YOUR-DEPLOYMENT-NAME.openai.azure.com +var azureOpenAIEndpoint = new Uri(new Uri(builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAI:Endpoint")), "/openai/v1"); + +#pragma warning disable OPENAI001 // The overload accepting an AuthenticationPolicy is experimental and may change or be removed in future releases. +var chatClient = new ChatClient( + "gpt-4o-mini", + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }) + .AsIChatClient(); +#pragma warning restore OPENAI001 + +builder.Services.AddChatClient(chatClient); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] +)); + +builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( + workflowName: key, + sp.GetRequiredKeyedService("writer"), + sp.GetRequiredKeyedService("editor") +)).AddAsAIAgent(); + +// Register services for OpenAI responses and conversations (also required for DevUI) +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); +app.UseHttpsRedirection(); + +// Map endpoints for OpenAI responses and conversations (also required for DevUI) +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +if (builder.Environment.IsDevelopment()) +{ + // Map DevUI endpoint to /devui + app.MapDevUI(); +} + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/Properties/launchSettings.json new file mode 100644 index 00000000000..c242aacc025 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "https://localhost:9999;http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/README.md new file mode 100644 index 00000000000..3cb72789072 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/README.md @@ -0,0 +1,107 @@ +# AI Agent Web API + +This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). + +## Prerequisites + +- An Azure OpenAI service deployment + +## Getting Started + +### 1. Configure Your AI Service + +#### Azure OpenAI Configuration + + +### 2. Run the Application + +```bash +dotnet run -lp https +``` + +The application will start and listen on: +- HTTP: `http://localhost:9999` +- HTTPS: `https://localhost:9999` + +### 3. Test the Application + +The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. + +In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. + +## How It Works + +This application demonstrates Agent Framework with: + +1. **Writer Agent**: Writes short stories (300 words or less) about specified topics +2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words +3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents + +The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. + +## Template Parameters + +When creating a new project, you can customize it using template parameters: + +```bash +# Specify AI service provider +dotnet new aiagent-webapi --provider azureopenai + +# Specify a custom chat model +dotnet new aiagent-webapi --chat-model gpt-4o + +# Use API key authentication for Azure OpenAI +dotnet new aiagent-webapi --provider azureopenai --managed-identity false + +# Use Ollama with a different model +dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 +``` + +### Available Parameters + +- **`--provider`**: Choose the AI service provider + - `githubmodels` (default) - GitHub Models + - `azureopenai` - Azure OpenAI + - `openai` - OpenAI Platform + - `ollama` - Ollama (local development) + +- **`--chat-model`**: Specify the chat model/deployment name + - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for Ollama: `llama3.2` + +- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) + - Only applicable when `--provider azureopenai` + +- **`--framework`**: Target framework (default: `net10.0`) + - Options: `net10.0`, `net9.0`, `net8.0` + +## Project Structure + +- `Program.cs` - Application entry point and configuration +- `appsettings.json` - Application configuration +- `Properties/launchSettings.json` - Launch profiles for development + +## Learn More + +- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) +- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) +- [Azure OpenAI Service](https://azure.microsoft.com/products/ai-services/openai-service) + +## Troubleshooting + +**Problem**: Application fails with "Missing configuration: AzureOpenAI:Endpoint" + +**Solution**: Make sure you've configured your Azure OpenAI endpoint using one of the methods described above. + +**Problem**: Managed identity authentication fails + +**Solution**: +- Ensure your Azure resource has a system-assigned or user-assigned managed identity enabled +- Verify the managed identity has been granted the "Cognitive Services OpenAI User" role on your Azure OpenAI resource +- For local development, ensure you're signed in to Azure CLI: `az login` + +**Problem**: API requests fail with authentication errors + +**Solution**: Verify your Azure OpenAI endpoint is correct and your managed identity has the correct permissions. + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/aiagent-webapi.csproj b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/aiagent-webapi.csproj new file mode 100644 index 00000000000..a915b9e428c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/aiagent-webapi.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + secret + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/appsettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/appsettings.json new file mode 100644 index 00000000000..223027717b4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.AzureOpenAI_ManagedIdentity.verified/aiagent-webapi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/Program.cs new file mode 100644 index 00000000000..025dffc9c9f --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/Program.cs @@ -0,0 +1,64 @@ +using System.ClientModel; +using System.ComponentModel; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DevUI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Chat; + +var builder = WebApplication.CreateBuilder(args); + +// You will need to set the token to your own value +// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: +// cd this-project-directory +// dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" +var chatClient = new ChatClient( + "gpt-4o-mini", + new ApiKeyCredential(builder.Configuration["GITHUB_TOKEN"] ?? throw new InvalidOperationException("Missing configuration: GITHUB_TOKEN")), + new OpenAIClientOptions { Endpoint = new Uri("https://models.inference.ai.azure.com") }) + .AsIChatClient(); + +builder.Services.AddChatClient(chatClient); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] +)); + +builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( + workflowName: key, + sp.GetRequiredKeyedService("writer"), + sp.GetRequiredKeyedService("editor") +)).AddAsAIAgent(); + +// Register services for OpenAI responses and conversations (also required for DevUI) +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); +app.UseHttpsRedirection(); + +// Map endpoints for OpenAI responses and conversations (also required for DevUI) +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +if (builder.Environment.IsDevelopment()) +{ + // Map DevUI endpoint to /devui + app.MapDevUI(); +} + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/Properties/launchSettings.json new file mode 100644 index 00000000000..c242aacc025 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "https://localhost:9999;http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/README.md new file mode 100644 index 00000000000..f13529d763e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/README.md @@ -0,0 +1,129 @@ +# AI Agent Web API + +This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). + +## Prerequisites + +- A GitHub Models API token (free to get started) + +## Getting Started + +### 1. Configure Your AI Service + +#### GitHub Models Configuration + +This application uses GitHub Models (model: gpt-4o-mini) for AI functionality. You'll need to configure your GitHub Models API token: + +**Option A: Using User Secrets (Recommended for Development)** + +```bash +dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" +``` + +**Option B: Using Environment Variables** + +Set the `GITHUB_TOKEN` environment variable: + +- **Windows (PowerShell)**: + ```powershell + $env:GITHUB_TOKEN = "your-github-models-token-here" + ``` + +- **Linux/macOS**: + ```bash + export GITHUB_TOKEN="your-github-models-token-here" + ``` + +#### Get a GitHub Models Token + +1. Visit [GitHub Models](https://github.com/marketplace/models) +2. Sign in with your GitHub account +3. Select a model (e.g., gpt-4o-mini) +4. Click "Get API Key" or follow the authentication instructions +5. Copy your personal access token + + +### 2. Run the Application + +```bash +dotnet run -lp https +``` + +The application will start and listen on: +- HTTP: `http://localhost:9999` +- HTTPS: `https://localhost:9999` + +### 3. Test the Application + +The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. + +In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. + +## How It Works + +This application demonstrates Agent Framework with: + +1. **Writer Agent**: Writes short stories (300 words or less) about specified topics +2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words +3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents + +The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. + +## Template Parameters + +When creating a new project, you can customize it using template parameters: + +```bash +# Specify AI service provider +dotnet new aiagent-webapi --provider azureopenai + +# Specify a custom chat model +dotnet new aiagent-webapi --chat-model gpt-4o + +# Use API key authentication for Azure OpenAI +dotnet new aiagent-webapi --provider azureopenai --managed-identity false + +# Use Ollama with a different model +dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 +``` + +### Available Parameters + +- **`--provider`**: Choose the AI service provider + - `githubmodels` (default) - GitHub Models + - `azureopenai` - Azure OpenAI + - `openai` - OpenAI Platform + - `ollama` - Ollama (local development) + +- **`--chat-model`**: Specify the chat model/deployment name + - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for Ollama: `llama3.2` + +- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) + - Only applicable when `--provider azureopenai` + +- **`--framework`**: Target framework (default: `net10.0`) + - Options: `net10.0`, `net9.0`, `net8.0` + +## Project Structure + +- `Program.cs` - Application entry point and configuration +- `appsettings.json` - Application configuration +- `Properties/launchSettings.json` - Launch profiles for development + +## Learn More + +- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) +- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) +- [GitHub Models](https://github.com/marketplace/models) + +## Troubleshooting + +**Problem**: Application fails with "Missing configuration: GITHUB_TOKEN" + +**Solution**: Make sure you've configured your GitHub Models API token using one of the methods described above. + +**Problem**: API requests fail with authentication errors + +**Solution**: Verify your GitHub Models token is valid and hasn't expired. You may need to regenerate it from the GitHub Models website. + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/aiagent-webapi.csproj b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/aiagent-webapi.csproj new file mode 100644 index 00000000000..337bfdd220c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/aiagent-webapi.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + secret + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/appsettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/appsettings.json new file mode 100644 index 00000000000..223027717b4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.DefaultParameters.verified/aiagent-webapi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/Program.cs new file mode 100644 index 00000000000..025dffc9c9f --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/Program.cs @@ -0,0 +1,64 @@ +using System.ClientModel; +using System.ComponentModel; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DevUI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Chat; + +var builder = WebApplication.CreateBuilder(args); + +// You will need to set the token to your own value +// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: +// cd this-project-directory +// dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" +var chatClient = new ChatClient( + "gpt-4o-mini", + new ApiKeyCredential(builder.Configuration["GITHUB_TOKEN"] ?? throw new InvalidOperationException("Missing configuration: GITHUB_TOKEN")), + new OpenAIClientOptions { Endpoint = new Uri("https://models.inference.ai.azure.com") }) + .AsIChatClient(); + +builder.Services.AddChatClient(chatClient); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] +)); + +builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( + workflowName: key, + sp.GetRequiredKeyedService("writer"), + sp.GetRequiredKeyedService("editor") +)).AddAsAIAgent(); + +// Register services for OpenAI responses and conversations (also required for DevUI) +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); +app.UseHttpsRedirection(); + +// Map endpoints for OpenAI responses and conversations (also required for DevUI) +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +if (builder.Environment.IsDevelopment()) +{ + // Map DevUI endpoint to /devui + app.MapDevUI(); +} + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/Properties/launchSettings.json new file mode 100644 index 00000000000..c242aacc025 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "https://localhost:9999;http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/README.md new file mode 100644 index 00000000000..f13529d763e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/README.md @@ -0,0 +1,129 @@ +# AI Agent Web API + +This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). + +## Prerequisites + +- A GitHub Models API token (free to get started) + +## Getting Started + +### 1. Configure Your AI Service + +#### GitHub Models Configuration + +This application uses GitHub Models (model: gpt-4o-mini) for AI functionality. You'll need to configure your GitHub Models API token: + +**Option A: Using User Secrets (Recommended for Development)** + +```bash +dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" +``` + +**Option B: Using Environment Variables** + +Set the `GITHUB_TOKEN` environment variable: + +- **Windows (PowerShell)**: + ```powershell + $env:GITHUB_TOKEN = "your-github-models-token-here" + ``` + +- **Linux/macOS**: + ```bash + export GITHUB_TOKEN="your-github-models-token-here" + ``` + +#### Get a GitHub Models Token + +1. Visit [GitHub Models](https://github.com/marketplace/models) +2. Sign in with your GitHub account +3. Select a model (e.g., gpt-4o-mini) +4. Click "Get API Key" or follow the authentication instructions +5. Copy your personal access token + + +### 2. Run the Application + +```bash +dotnet run -lp https +``` + +The application will start and listen on: +- HTTP: `http://localhost:9999` +- HTTPS: `https://localhost:9999` + +### 3. Test the Application + +The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. + +In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. + +## How It Works + +This application demonstrates Agent Framework with: + +1. **Writer Agent**: Writes short stories (300 words or less) about specified topics +2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words +3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents + +The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. + +## Template Parameters + +When creating a new project, you can customize it using template parameters: + +```bash +# Specify AI service provider +dotnet new aiagent-webapi --provider azureopenai + +# Specify a custom chat model +dotnet new aiagent-webapi --chat-model gpt-4o + +# Use API key authentication for Azure OpenAI +dotnet new aiagent-webapi --provider azureopenai --managed-identity false + +# Use Ollama with a different model +dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 +``` + +### Available Parameters + +- **`--provider`**: Choose the AI service provider + - `githubmodels` (default) - GitHub Models + - `azureopenai` - Azure OpenAI + - `openai` - OpenAI Platform + - `ollama` - Ollama (local development) + +- **`--chat-model`**: Specify the chat model/deployment name + - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for Ollama: `llama3.2` + +- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) + - Only applicable when `--provider azureopenai` + +- **`--framework`**: Target framework (default: `net10.0`) + - Options: `net10.0`, `net9.0`, `net8.0` + +## Project Structure + +- `Program.cs` - Application entry point and configuration +- `appsettings.json` - Application configuration +- `Properties/launchSettings.json` - Launch profiles for development + +## Learn More + +- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) +- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) +- [GitHub Models](https://github.com/marketplace/models) + +## Troubleshooting + +**Problem**: Application fails with "Missing configuration: GITHUB_TOKEN" + +**Solution**: Make sure you've configured your GitHub Models API token using one of the methods described above. + +**Problem**: API requests fail with authentication errors + +**Solution**: Verify your GitHub Models token is valid and hasn't expired. You may need to regenerate it from the GitHub Models website. + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/aiagent-webapi.csproj b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/aiagent-webapi.csproj new file mode 100644 index 00000000000..337bfdd220c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/aiagent-webapi.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + secret + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/appsettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/appsettings.json new file mode 100644 index 00000000000..223027717b4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.GitHubModels.verified/aiagent-webapi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/Program.cs new file mode 100644 index 00000000000..2e9c6d25484 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/Program.cs @@ -0,0 +1,56 @@ +using System.ComponentModel; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DevUI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using OllamaSharp; + +var builder = WebApplication.CreateBuilder(args); + +// You will need to have Ollama running locally with the llama3.2 model installed +// Visit https://ollama.com for installation instructions +var chatClient = new OllamaApiClient(new Uri("http://localhost:11434"), "llama3.2"); + +builder.Services.AddChatClient(chatClient); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] +)); + +builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( + workflowName: key, + sp.GetRequiredKeyedService("writer"), + sp.GetRequiredKeyedService("editor") +)).AddAsAIAgent(); + +// Register services for OpenAI responses and conversations (also required for DevUI) +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); +app.UseHttpsRedirection(); + +// Map endpoints for OpenAI responses and conversations (also required for DevUI) +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +if (builder.Environment.IsDevelopment()) +{ + // Map DevUI endpoint to /devui + app.MapDevUI(); +} + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/Properties/launchSettings.json new file mode 100644 index 00000000000..c242aacc025 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "https://localhost:9999;http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/README.md new file mode 100644 index 00000000000..411f6c90acc --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/README.md @@ -0,0 +1,112 @@ +# AI Agent Web API + +This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). + +## Prerequisites + +- Ollama installed locally with the llama3.2 model + +## Getting Started + +### 1. Configure Your AI Service + +#### Ollama Configuration + +This application uses Ollama running locally (model: llama3.2). You'll need to have Ollama installed and the llama3.2 model downloaded: + +1. Visit [Ollama](https://ollama.com) and follow the installation instructions for your platform +2. Once installed, download the llama3.2 model: + ```bash + ollama pull llama3.2 + ``` +3. Ensure Ollama is running (it starts automatically after installation) + +The application is configured to connect to Ollama at `http://localhost:9999`. + + +### 2. Run the Application + +```bash +dotnet run -lp https +``` + +The application will start and listen on: +- HTTP: `http://localhost:9999` +- HTTPS: `https://localhost:9999` + +### 3. Test the Application + +The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. + +In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. + +## How It Works + +This application demonstrates Agent Framework with: + +1. **Writer Agent**: Writes short stories (300 words or less) about specified topics +2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words +3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents + +The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. + +## Template Parameters + +When creating a new project, you can customize it using template parameters: + +```bash +# Specify AI service provider +dotnet new aiagent-webapi --provider azureopenai + +# Specify a custom chat model +dotnet new aiagent-webapi --chat-model gpt-4o + +# Use API key authentication for Azure OpenAI +dotnet new aiagent-webapi --provider azureopenai --managed-identity false + +# Use Ollama with a different model +dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 +``` + +### Available Parameters + +- **`--provider`**: Choose the AI service provider + - `githubmodels` (default) - GitHub Models + - `azureopenai` - Azure OpenAI + - `openai` - OpenAI Platform + - `ollama` - Ollama (local development) + +- **`--chat-model`**: Specify the chat model/deployment name + - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for Ollama: `llama3.2` + +- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) + - Only applicable when `--provider azureopenai` + +- **`--framework`**: Target framework (default: `net10.0`) + - Options: `net10.0`, `net9.0`, `net8.0` + +## Project Structure + +- `Program.cs` - Application entry point and configuration +- `appsettings.json` - Application configuration +- `Properties/launchSettings.json` - Launch profiles for development + +## Learn More + +- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) +- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) +- [Ollama](https://ollama.com) + +## Troubleshooting + +**Problem**: Application fails to connect to Ollama + +**Solution**: +- Ensure Ollama is running. On macOS/Linux, check with `pgrep ollama`. On Windows, check Task Manager. +- Verify Ollama is accessible at `http://localhost:9999` +- Make sure you've downloaded the llama3.2 model: `ollama pull llama3.2` + +**Problem**: Model responses are slow or time out + +**Solution**: Ollama runs locally and performance depends on your hardware. Consider using a smaller model or ensuring your system has adequate resources (RAM, GPU if available). diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/aiagent-webapi.csproj b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/aiagent-webapi.csproj new file mode 100644 index 00000000000..2bdbe9ad84a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/aiagent-webapi.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + secret + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/appsettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/appsettings.json new file mode 100644 index 00000000000..223027717b4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.Ollama.verified/aiagent-webapi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/Program.cs new file mode 100644 index 00000000000..15009d121cd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/Program.cs @@ -0,0 +1,62 @@ +using System.ClientModel; +using System.ComponentModel; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DevUI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +var builder = WebApplication.CreateBuilder(args); + +// You will need to set the API key to your own value +// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: +// cd this-project-directory +// dotnet user-secrets set "OPENAI_KEY" "your-openai-api-key-here" +var chatClient = new ChatClient( + "gpt-4o-mini", + new ApiKeyCredential(builder.Configuration["OPENAI_KEY"] ?? throw new InvalidOperationException("Missing configuration: OPENAI_KEY"))) + .AsIChatClient(); + +builder.Services.AddChatClient(chatClient); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] +)); + +builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( + workflowName: key, + sp.GetRequiredKeyedService("writer"), + sp.GetRequiredKeyedService("editor") +)).AddAsAIAgent(); + +// Register services for OpenAI responses and conversations (also required for DevUI) +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); +app.UseHttpsRedirection(); + +// Map endpoints for OpenAI responses and conversations (also required for DevUI) +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +if (builder.Environment.IsDevelopment()) +{ + // Map DevUI endpoint to /devui + app.MapDevUI(); +} + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/Properties/launchSettings.json new file mode 100644 index 00000000000..c242aacc025 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "https://localhost:9999;http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/README.md new file mode 100644 index 00000000000..a989e78b7d7 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/README.md @@ -0,0 +1,129 @@ +# AI Agent Web API + +This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). + +## Prerequisites + +- An OpenAI API key + +## Getting Started + +### 1. Configure Your AI Service + +#### OpenAI Configuration + +This application uses the OpenAI Platform (model: gpt-4o-mini). You'll need to configure your OpenAI API key: + +**Using User Secrets (Recommended for Development)** + +```bash +dotnet user-secrets set "OPENAI_KEY" "your-openai-api-key-here" +``` + +**Using Environment Variables** + +Set the `OPENAI_KEY` environment variable: + +- **Windows (PowerShell)**: + ```powershell + $env:OPENAI_KEY = "your-openai-api-key-here" + ``` + +- **Linux/macOS**: + ```bash + export OPENAI_KEY="your-openai-api-key-here" + ``` + +#### Get an OpenAI API Key + +1. Visit [OpenAI Platform](https://platform.openai.com) +2. Sign in or create an account +3. Navigate to API Keys +4. Create a new API key +5. Copy your API key + + +### 2. Run the Application + +```bash +dotnet run -lp https +``` + +The application will start and listen on: +- HTTP: `http://localhost:9999` +- HTTPS: `https://localhost:9999` + +### 3. Test the Application + +The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. + +In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. + +## How It Works + +This application demonstrates Agent Framework with: + +1. **Writer Agent**: Writes short stories (300 words or less) about specified topics +2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words +3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents + +The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. + +## Template Parameters + +When creating a new project, you can customize it using template parameters: + +```bash +# Specify AI service provider +dotnet new aiagent-webapi --provider azureopenai + +# Specify a custom chat model +dotnet new aiagent-webapi --chat-model gpt-4o + +# Use API key authentication for Azure OpenAI +dotnet new aiagent-webapi --provider azureopenai --managed-identity false + +# Use Ollama with a different model +dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 +``` + +### Available Parameters + +- **`--provider`**: Choose the AI service provider + - `githubmodels` (default) - GitHub Models + - `azureopenai` - Azure OpenAI + - `openai` - OpenAI Platform + - `ollama` - Ollama (local development) + +- **`--chat-model`**: Specify the chat model/deployment name + - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for Ollama: `llama3.2` + +- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) + - Only applicable when `--provider azureopenai` + +- **`--framework`**: Target framework (default: `net10.0`) + - Options: `net10.0`, `net9.0`, `net8.0` + +## Project Structure + +- `Program.cs` - Application entry point and configuration +- `appsettings.json` - Application configuration +- `Properties/launchSettings.json` - Launch profiles for development + +## Learn More + +- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) +- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) +- [OpenAI Platform](https://platform.openai.com) + +## Troubleshooting + +**Problem**: Application fails with "Missing configuration: OPENAI_KEY" + +**Solution**: Make sure you've configured your OpenAI API key using one of the methods described above. + +**Problem**: API requests fail with authentication errors + +**Solution**: Verify your OpenAI API key is valid. Check your usage limits and billing status on the OpenAI Platform. + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/aiagent-webapi.csproj b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/aiagent-webapi.csproj new file mode 100644 index 00000000000..337bfdd220c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/aiagent-webapi.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + secret + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/appsettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/appsettings.json new file mode 100644 index 00000000000..223027717b4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi.OpenAI.verified/aiagent-webapi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/WebApiAgentTemplateExecutionTests.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/WebApiAgentTemplateExecutionTests.cs new file mode 100644 index 00000000000..2462a7bd002 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/WebApiAgentTemplateExecutionTests.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Shared.ProjectTemplates.Tests; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.ProjectTemplates.Tests; + +/// +/// Contains execution tests for the "AI Agent Web API" template. +/// +/// +/// In addition to validating that the templates build and restore correctly, +/// these tests are also responsible for template component governance reporting. +/// This is because the generated output is left on disk after tests complete, +/// most importantly the project.assets.json file that gets created during restore. +/// Therefore, it's *critical* that these tests remain in a working state, +/// as disabling them will also disable CG reporting. +/// +public class WebApiAgentTemplateExecutionTests : TemplateExecutionTestBase, ITemplateExecutionTestConfigurationProvider +{ + public WebApiAgentTemplateExecutionTests(TemplateExecutionTestFixture fixture, ITestOutputHelper outputHelper) + : base(fixture, outputHelper) + { + } + + public static TemplateExecutionTestConfiguration Configuration { get; } = new() + { + TemplatePackageName = "Microsoft.Agents.AI.ProjectTemplates", + TestOutputFolderPrefix = "WebApiAgent" + }; + + public static IEnumerable GetTemplateOptions() + => GetFilteredTemplateOptions(); + + // Do not skip. See XML docs for this test class. + [Theory] + [MemberData(nameof(GetTemplateOptions))] + public async Task CreateRestoreAndBuild(params string[] args) + { + const string ProjectName = "WebApiAgentApp"; + var project = await Fixture.CreateProjectAsync( + templateName: "aiagent-webapi", + projectName: ProjectName, + args); + + await Fixture.RestoreProjectAsync(project); + await Fixture.BuildProjectAsync(project); + } + + private static readonly (string name, string[] values)[] _templateOptions = [ + ("--provider", ["azureopenai", "githubmodels", "ollama", "openai"]), + ("--managed-identity", ["true", "false"]), + ]; + + private static IEnumerable GetFilteredTemplateOptions(params string[] filter) + { + foreach (var options in GetAllPossibleOptions(_templateOptions)) + { + if (!MatchesFilter()) + { + continue; + } + + if (HasOption("--managed-identity", "true") && !HasOption("--provider", "azureopenai")) + { + // The managed identity option is only valid for Azure OpenAI. + continue; + } + + yield return options; + + bool MatchesFilter() + { + for (var i = 0; i < filter.Length; i += 2) + { + if (!HasOption(filter[i], filter[i + 1])) + { + return false; + } + } + + return true; + } + + bool HasOption(string name, string value) + { + for (var i = 0; i < options.Length; i += 2) + { + if (string.Equals(name, options[i], StringComparison.Ordinal) && + string.Equals(value, options[i + 1], StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + } + } + + private static IEnumerable GetAllPossibleOptions(ReadOnlyMemory<(string name, string[] values)> options) + { + if (options.Length == 0) + { + yield return []; + yield break; + } + + var first = options.Span[0]; + foreach (var restSelection in GetAllPossibleOptions(options[1..])) + { + foreach (var value in first.values) + { + yield return [first.name, value, .. restSelection]; + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/WebApiAgentTemplateSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/WebApiAgentTemplateSnapshotTests.cs new file mode 100644 index 00000000000..dce7ca19646 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/WebApiAgentTemplateSnapshotTests.cs @@ -0,0 +1,139 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.ProjectTemplates.Tests; +using Microsoft.TemplateEngine.Authoring.TemplateVerifier; +using Microsoft.TemplateEngine.TestHelper; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.ProjectTemplates.Tests; + +public class WebApiAgentTemplateSnapshotTests +{ + // Keep the exclude patterns below in sync with those in Microsoft.Agents.AI.ProjectTemplates.csproj. + private static readonly string[] _verificationExcludePatterns = [ + "**/bin/**", + "**/obj/**", + "**/.vs/**", + "**/*.user", + "**/*.in", + "**/NuGet.config", + "**/Directory.Build.targets", + "**/Directory.Build.props" + ]; + + private readonly ILogger _log; + + public WebApiAgentTemplateSnapshotTests(ITestOutputHelper log) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + _log = new XunitLoggerProvider(log).CreateLogger("TestRun"); +#pragma warning restore CA2000 // Dispose objects before losing scope + } + + [Fact] + public async Task DefaultParameters() + { + await TestTemplateCoreAsync(scenarioName: nameof(DefaultParameters)); + } + + [Fact] + public async Task GitHubModels() + { + await TestTemplateCoreAsync(scenarioName: nameof(GitHubModels), templateArgs: ["--provider", "githubmodels"]); + } + + [Fact] + public async Task OpenAI() + { + await TestTemplateCoreAsync(scenarioName: nameof(OpenAI), templateArgs: ["--provider", "openai"]); + } + + [Fact] + public async Task AzureOpenAI_ManagedIdentity() + { + await TestTemplateCoreAsync(scenarioName: nameof(AzureOpenAI_ManagedIdentity), templateArgs: ["--provider", "azureopenai"]); + } + + [Fact] + public async Task AzureOpenAI_ApiKey() + { + await TestTemplateCoreAsync(scenarioName: nameof(AzureOpenAI_ApiKey), templateArgs: ["--provider", "azureopenai", "--managed-identity", "false"]); + } + + [Fact] + public async Task Ollama() + { + await TestTemplateCoreAsync(scenarioName: nameof(Ollama), templateArgs: ["--provider", "ollama"]); + } + + private async Task TestTemplateCoreAsync(string scenarioName, IEnumerable? templateArgs = null) + { + string workingDir = TestUtils.CreateTemporaryFolder(); + string templateShortName = "aiagent-webapi"; + + // Get the template location + string templateLocation = Path.Combine(WellKnownPaths.TemplateFeedLocation, "Microsoft.Agents.AI.ProjectTemplates", "src", "WebApiAgent"); + + var verificationExcludePatterns = Path.DirectorySeparatorChar is '/' + ? _verificationExcludePatterns + : _verificationExcludePatterns.Select(p => p.Replace('/', Path.DirectorySeparatorChar)).ToArray(); + + TemplateVerifierOptions options = new TemplateVerifierOptions(templateName: templateShortName) + { + TemplatePath = templateLocation, + TemplateSpecificArgs = templateArgs, + SnapshotsDirectory = "Snapshots", + OutputDirectory = workingDir, + DoNotPrependCallerMethodNameToScenarioName = true, + DoNotAppendTemplateArgsToScenarioName = true, + ScenarioName = scenarioName, + VerificationExcludePatterns = verificationExcludePatterns, + } + .WithCustomScrubbers( + ScrubbersDefinition.Empty.AddScrubber((path, content) => + { + string filePath = path.UnixifyDirSeparators(); + if (filePath.EndsWith(".sln")) + { + // Scrub .sln file GUIDs. + content.ScrubByRegex(pattern: @"\{.{36}\}", replacement: "{00000000-0000-0000-0000-000000000000}"); + } + + if (filePath.EndsWith(".csproj")) + { + content.ScrubByRegex("(.*)<\\/UserSecretsId>", "secret"); + + // Scrub references to just-built packages and remove the suffix, if it exists. + // This allows the snapshots to remain the same regardless of where the repo is built (e.g., locally, public CI, internal CI). + var pattern = @"(?<=)"; + content.ScrubByRegex(pattern, replacement: "$2"); + } + + if (filePath.EndsWith("launchSettings.json") || filePath.EndsWith("README.md")) + { + content.ScrubByRegex("(http(s?):\\/\\/localhost)\\:(\\d*)", "$1:9999"); + } + })); + + VerificationEngine engine = new VerificationEngine(_log); + await engine.Execute(options); + +#pragma warning disable CA1031 // Do not catch general exception types + try + { + Directory.Delete(workingDir, recursive: true); + } + catch + { + /* don't care */ + } +#pragma warning restore CA1031 // Do not catch general exception types + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs index f5d2bc52e3a..1f82e25f135 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Shared.ProjectTemplates.Tests; using Microsoft.TestUtilities; using Xunit; using Xunit.Abstractions; @@ -115,6 +116,7 @@ private static readonly (string name, string[] values)[] _templateOptions = [ ("--vector-store", ["azureaisearch", "local", "qdrant"]), ("--managed-identity", ["true", "false"]), ("--aspire", ["true", "false"]), + ("--Framework", ["net9.0", "net10.0"]) ]; private static IEnumerable GetFilteredTemplateOptions(params string[] filter) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs index 4bf84ab4bd3..487d83834a4 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Shared.ProjectTemplates.Tests; using Microsoft.TemplateEngine.Authoring.TemplateVerifier; using Microsoft.TemplateEngine.TestHelper; using Xunit; diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs deleted file mode 100644 index 4b5e2dd2a28..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs +++ /dev/null @@ -1,15 +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.Text; - -namespace Microsoft.Extensions.AI.Templates.Tests; - -public sealed class TestCommandResult(StringBuilder standardOutputBuilder, StringBuilder standardErrorBuilder, int exitCode) -{ - public string StandardOutput => field ??= standardOutputBuilder.ToString(); - - public string StandardError => field ??= standardErrorBuilder.ToString(); - - public int ExitCode => exitCode; -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResultExtensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResultExtensions.cs deleted file mode 100644 index 867cc2303ac..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResultExtensions.cs +++ /dev/null @@ -1,27 +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 Xunit; - -namespace Microsoft.Extensions.AI.Templates.Tests; - -public static class TestCommandResultExtensions -{ - public static TestCommandResult AssertZeroExitCode(this TestCommandResult result) - { - Assert.True(result.ExitCode == 0, $"Expected an exit code of zero, got {result.ExitCode}"); - return result; - } - - public static TestCommandResult AssertEmptyStandardError(this TestCommandResult result) - { - var standardError = result.StandardError; - Assert.True(string.IsNullOrWhiteSpace(standardError), $"Standard error output was unexpectedly non-empty:\n{standardError}"); - return result; - } - - public static TestCommandResult AssertSucceeded(this TestCommandResult result) - => result - .AssertZeroExitCode() - .AssertEmptyStandardError(); -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs index 5fa1723af83..ccfce67367a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Shared.ProjectTemplates.Tests; using Microsoft.TemplateEngine.Authoring.TemplateVerifier; using Microsoft.TemplateEngine.TestHelper; using Xunit; diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj index e4c52714b79..bd83d4fa93e 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj @@ -7,7 +7,7 @@ - $(NoWarn);CA1063;CA1861;CA2201;VSTHRD003;S104;S125;S2699 + $(NoWarn);CA1063;CA1716;CA1861;CA2201;VSTHRD003;S104;S125;S2699 @@ -20,11 +20,13 @@ + + - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/ProjectRootHelper.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/ProjectRootHelper.cs index 3d076a438ad..97be1100c35 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/ProjectRootHelper.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/ProjectRootHelper.cs @@ -1,11 +1,11 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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.IO; using System.Runtime.CompilerServices; -namespace Microsoft.Extensions.AI.Templates.Tests; +namespace Microsoft.Shared.ProjectTemplates.Tests; /// /// Contains a helper for determining the disk location of the containing project folder. diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs index da0220a0b1c..c8d2d1a8228 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs @@ -18,6 +18,10 @@ // for instructions providing configuration values var search = builder.AddAzureSearch("search"); +var markitdown = builder.AddContainer("markitdown", "mcp/markitdown") + .WithArgs("--http", "--host", "0.0.0.0", "--port", "3001") + .WithHttpEndpoint(targetPort: 3001, name: "http"); + var webApp = builder.AddProject("aichatweb-app"); webApp .WithReference(openai) @@ -25,5 +29,7 @@ webApp .WithReference(search) .WaitFor(search); +webApp + .WithEnvironment("MARKITDOWN_MCP_URL", markitdown.GetEndpoint("http")); builder.Build().Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj index d2d0f2890a6..4c958e078a0 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -4,7 +4,7 @@ Exe - net9.0 + net10.0 enable enable secret diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs index b44d60b604b..8d0b0cd5d67 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs @@ -76,7 +76,8 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() .AddHttpClientInstrumentation() - .AddSource("Experimental.Microsoft.Extensions.AI"); + .AddSource("Experimental.Microsoft.Extensions.AI") + .AddSource("Experimental.Microsoft.Extensions.DataIngestion"); }); builder.AddOpenTelemetryExporters(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index 474dd445fae..a70a3ca8cd4 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable true diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor index 8aa0ec9fd28..6fc5881c18f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -13,7 +13,7 @@
To get started, try asking about these example documents. You can replace these with your own data and replace this message.
- +
@@ -29,10 +29,12 @@ Do not answer questions about anything else. Use only simple markdown to format your responses. - Use the search tool to find relevant information. When you do this, end your + Use the LoadDocuments tool to prepare for searches before answering any questions. + + Use the Search tool to find relevant information. When you do this, end your reply with citations in the special XML format: - exact quote here + exact quote here Always include the citation in your response if there are results. @@ -52,7 +54,10 @@ { statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); - chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; + chatOptions.Tools = [ + AIFunctionFactory.Create(LoadDocumentsAsync), + AIFunctionFactory.Create(SearchAsync) + ]; } private async Task AddUserMessageAsync(ChatMessage userMessage) @@ -106,7 +111,14 @@ await chatInput!.FocusAsync(); } - [Description("Searches for information using a phrase or keyword")] + [Description("Loads the documents needed for performing searches. Must be completed before a search can be executed, but only needs to be completed once.")] + private async Task LoadDocumentsAsync() + { + await InvokeAsync(StateHasChanged); + await Search.LoadDocumentsAsync(); + } + + [Description("Searches for information using a phrase or keyword. Relies on documents already being loaded.")] private async Task> SearchAsync( [Description("The phrase to search for.")] string searchPhrase, [Description("If possible, specify the filename to search that file only. If not provided or empty, the search includes all files.")] string? filenameFilter = null) @@ -114,7 +126,7 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + $"{result.Text}"); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor index ccb5853cec4..667189beabd 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor @@ -17,10 +17,7 @@ public required string File { get; set; } [Parameter] - public int? PageNumber { get; set; } - - [Parameter] - public required string Quote { get; set; } + public string? Quote { get; set; } private string? viewerUrl; @@ -28,11 +25,15 @@ { viewerUrl = null; - // If you ingest other types of content besides PDF files, construct a URL to an appropriate viewer here - if (File.EndsWith(".pdf")) + // If you ingest other types of content besides Markdown or PDF files, construct a URL to an appropriate viewer here + if (File.EndsWith(".md")) + { + viewerUrl = $"lib/markdown_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#:~:text={Uri.EscapeDataString(Quote ?? "")}"; + } + else if (File.EndsWith(".pdf")) { var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); - viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#page={PageNumber}&search={HttpUtility.UrlEncode(search)}&phrase=true"; + viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#search={HttpUtility.UrlEncode(search)}&phrase=true"; } } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor index 92c20c70667..e45d92ab5f9 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -28,11 +28,24 @@ else if (Message.Role == ChatRole.Assistant) @foreach (var citation in citations ?? []) { - + } } + else if (content is FunctionCallContent { Name: "LoadDocuments" }) + { + + } else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) { } + else if (content is FunctionCallContent { Name: "LoadDocuments" }) + { + + } else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) { } + else if (content is FunctionCallContent { Name: "LoadDocuments" }) + { + + } else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) { } + else if (content is FunctionCallContent { Name: "LoadDocuments" }) + { + + } else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) { } + else if (content is FunctionCallContent { Name: "LoadDocuments" }) + { + + } else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) {