From 7da0035c19d23db10b360a8ae141d8d203b59cd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:01:35 +0000 Subject: [PATCH 1/9] Initial plan From 10f461eaaf992bd6a83d5501ce7ca93d51819c8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:31:37 +0000 Subject: [PATCH 2/9] Implement HostedToolSearchTool and SearchableAIFunctionDeclaration for tool search support Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- global.json | 2 +- .../DelegatingAIFunctionDeclaration.cs | 2 +- .../SearchableAIFunctionDeclaration.cs | 62 +++++++++++ .../Microsoft.Extensions.AI.Abstractions.json | 88 +++++++++++++++ .../Tools/HostedToolSearchTool.cs | 38 +++++++ .../OpenAIJsonContext.cs | 1 + .../OpenAIResponsesChatClient.cs | 18 +++- src/Shared/DiagnosticIds/DiagnosticIds.cs | 1 + .../SearchableAIFunctionDeclarationTests.cs | 102 ++++++++++++++++++ .../Tools/HostedToolSearchToolTests.cs | 38 +++++++ .../OpenAIConversionTests.cs | 53 +++++++++ 11 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs diff --git a/global.json b/global.json index 8decbcb016e..06ad2d78bce 100644 --- a/global.json +++ b/global.json @@ -23,4 +23,4 @@ "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.26123.3", "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.26123.3" } -} +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs index 38ebcf0ffd9..3d509aeff68 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI; /// /// Provides an optional base class for an that passes through calls to another instance. /// -internal class DelegatingAIFunctionDeclaration : AIFunctionDeclaration // could be made public in the future if there's demand +public class DelegatingAIFunctionDeclaration : AIFunctionDeclaration { /// /// Initializes a new instance of the class as a wrapper around . diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs new file mode 100644 index 00000000000..c49b723e5bf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs @@ -0,0 +1,62 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an that signals to supporting AI services that deferred +/// loading should be used when tool search is enabled. Only the function's name and description are sent initially; +/// the full JSON schema is loaded on demand by the service when the model selects this tool. +/// +/// +/// This class is a marker/decorator that signals to a supporting provider that the function should be +/// sent with deferred loading (only name and description upfront). Use to create +/// a complete tool list including a and wrapped functions. +/// +[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class SearchableAIFunctionDeclaration : DelegatingAIFunctionDeclaration +{ + /// + /// Initializes a new instance of the class. + /// + /// The represented by this instance. + /// An optional namespace for grouping related tools in the tool search index. + /// is . + public SearchableAIFunctionDeclaration(AIFunctionDeclaration innerFunction, string? namespaceName = null) + : base(innerFunction) + { + Namespace = namespaceName; + } + + /// Gets the optional namespace this function belongs to, for grouping related tools in the tool search index. + public string? Namespace { get; } + + /// + /// Creates a complete tool list with a and the given functions wrapped as . + /// + /// The functions to include as searchable tools. + /// An optional namespace for grouping related tools. + /// Any additional properties to pass to the . + /// A list of instances ready for use in . + /// is . + public static IList CreateToolSet( + IEnumerable functions, + string? namespaceName = null, + IReadOnlyDictionary? toolSearchProperties = null) + { + _ = Throw.IfNull(functions); + + var tools = new List { new HostedToolSearchTool(toolSearchProperties) }; + foreach (var fn in functions) + { + tools.Add(new SearchableAIFunctionDeclaration(fn, namespaceName)); + } + + return tools; + } +} 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 d9f97f58c97..07dbe187606 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 @@ -1619,6 +1619,50 @@ } ] }, + { + "Type": "class Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration : Microsoft.Extensions.AI.AIFunctionDeclaration", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.DelegatingAIFunctionDeclaration(Microsoft.Extensions.AI.AIFunctionDeclaration innerFunction);", + "Stage": "Stable" + }, + { + "Member": "override object? Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.AdditionalProperties { get; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.Description { get; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIFunctionDeclaration Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.InnerFunction { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Text.Json.JsonElement Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.JsonSchema { get; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.Name { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Text.Json.JsonElement? Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.ReturnJsonSchema { get; }", + "Stage": "Stable" + } + ] + }, { "Type": "class Microsoft.Extensions.AI.DelegatingChatClient : Microsoft.Extensions.AI.IChatClient, System.IDisposable", "Stage": "Stable", @@ -2305,6 +2349,30 @@ } ] }, + { + "Type": "class Microsoft.Extensions.AI.HostedToolSearchTool : Microsoft.Extensions.AI.AITool", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.HostedToolSearchTool.HostedToolSearchTool();", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.HostedToolSearchTool.HostedToolSearchTool(System.Collections.Generic.IReadOnlyDictionary? additionalProperties);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.HostedToolSearchTool.AdditionalProperties { get; }", + "Stage": "Experimental" + }, + { + "Member": "override string Microsoft.Extensions.AI.HostedToolSearchTool.Name { get; }", + "Stage": "Experimental" + } + ] + }, { "Type": "sealed class Microsoft.Extensions.AI.HostedVectorStoreContent : Microsoft.Extensions.AI.AIContent", "Stage": "Stable", @@ -2882,6 +2950,26 @@ } ] }, + { + "Type": "sealed class Microsoft.Extensions.AI.SearchableAIFunctionDeclaration : Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.SearchableAIFunctionDeclaration.SearchableAIFunctionDeclaration(Microsoft.Extensions.AI.AIFunctionDeclaration innerFunction, string? namespaceName = null);", + "Stage": "Experimental" + }, + { + "Member": "static System.Collections.Generic.IList Microsoft.Extensions.AI.SearchableAIFunctionDeclaration.CreateToolSet(System.Collections.Generic.IEnumerable functions, string? namespaceName = null, System.Collections.Generic.IReadOnlyDictionary? toolSearchProperties = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.SearchableAIFunctionDeclaration.Namespace { get; }", + "Stage": "Experimental" + } + ] + }, { "Type": "static class Microsoft.Extensions.AI.SpeechToTextClientExtensions", "Stage": "Experimental", diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs new file mode 100644 index 00000000000..4fd90e06449 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs @@ -0,0 +1,38 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents a hosted tool that can be specified to an AI service to enable it to search for and selectively load tool definitions on demand. +/// +/// This tool does not itself implement tool search. It is a marker that can be used to inform a service +/// that tool search should be enabled, reducing token usage by deferring full tool schema loading until the model requests it. +/// +[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] +public class HostedToolSearchTool : AITool +{ + /// Any additional properties associated with the tool. + private IReadOnlyDictionary? _additionalProperties; + + /// Initializes a new instance of the class. + public HostedToolSearchTool() + { + } + + /// Initializes a new instance of the class. + /// Any additional properties associated with the tool. + public HostedToolSearchTool(IReadOnlyDictionary? additionalProperties) + { + _additionalProperties = additionalProperties; + } + + /// + public override string Name => "tool_search"; + + /// + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs index 9a040864613..fcdf957762b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs @@ -17,6 +17,7 @@ namespace Microsoft.Extensions.AI; WriteIndented = true)] [JsonSerializable(typeof(OpenAIClientExtensions.ToolJson))] [JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(string))] [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(JsonElement))] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index de1cdd4b127..2002f269a5d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -50,6 +50,9 @@ private static readonly Func>)); + /// Cached deserialized for the tool_search hosted tool. + private static ResponseTool? s_toolSearchResponseTool; + /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -690,7 +693,20 @@ void IDisposable.Dispose() return rtat.Tool; case AIFunctionDeclaration aiFunction: - return ToResponseTool(aiFunction, options); + var functionTool = ToResponseTool(aiFunction, options); + if (tool.GetService() is { } searchable) + { + functionTool.Patch.Set("$.defer_loading"u8, JsonSerializer.SerializeToUtf8Bytes(true).AsSpan()); + if (searchable.Namespace is { } ns) + { + functionTool.Patch.Set("$.namespace"u8, JsonSerializer.SerializeToUtf8Bytes(ns, OpenAIJsonContext.Default.String).AsSpan()); + } + } + + return functionTool; + + case HostedToolSearchTool: + return s_toolSearchResponseTool ??= ModelReaderWriter.Read(BinaryData.FromString("""{"type": "tool_search"}"""))!; case HostedWebSearchTool webSearchTool: return new WebSearchTool diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs index 92dd69462c9..27d1048e9f8 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -57,6 +57,7 @@ internal static class Experiments internal const string AIResponseContinuations = AIExperiments; internal const string AICodeInterpreter = AIExperiments; internal const string AIWebSearch = AIExperiments; + internal const string AIToolSearch = AIExperiments; internal const string AIRealTime = AIExperiments; internal const string AIFiles = AIExperiments; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs new file mode 100644 index 00000000000..98af5640597 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs @@ -0,0 +1,102 @@ +// 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 Xunit; + +namespace Microsoft.Extensions.AI.Functions; + +public class SearchableAIFunctionDeclarationTests +{ + [Fact] + public void Constructor_NullFunction_ThrowsArgumentNullException() + { + Assert.Throws("innerFunction", () => new SearchableAIFunctionDeclaration(null!)); + } + + [Fact] + public void Constructor_DelegatesToInnerFunction_Properties() + { + var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var wrapper = new SearchableAIFunctionDeclaration(inner); + + Assert.Equal(inner.Name, wrapper.Name); + Assert.Equal(inner.Description, wrapper.Description); + Assert.Equal(inner.JsonSchema, wrapper.JsonSchema); + Assert.Equal(inner.ReturnJsonSchema, wrapper.ReturnJsonSchema); + Assert.Same(inner.AdditionalProperties, wrapper.AdditionalProperties); + Assert.Equal(inner.ToString(), wrapper.ToString()); + } + + [Fact] + public void Namespace_DefaultIsNull() + { + var inner = AIFunctionFactory.Create(() => 42); + var wrapper = new SearchableAIFunctionDeclaration(inner); + + Assert.Null(wrapper.Namespace); + } + + [Fact] + public void Namespace_Roundtrips() + { + var inner = AIFunctionFactory.Create(() => 42); + var wrapper = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace"); + + Assert.Equal("myNamespace", wrapper.Namespace); + } + + [Fact] + public void GetService_ReturnsSelf() + { + var inner = AIFunctionFactory.Create(() => 42); + var wrapper = new SearchableAIFunctionDeclaration(inner); + + Assert.Same(wrapper, wrapper.GetService()); + } + + [Fact] + public void CreateToolSet_NullFunctions_Throws() + { + Assert.Throws("functions", () => SearchableAIFunctionDeclaration.CreateToolSet(null!)); + } + + [Fact] + public void CreateToolSet_ReturnsHostedToolSearchToolFirst_ThenWrappedFunctions() + { + var f1 = AIFunctionFactory.Create(() => 1, "F1"); + var f2 = AIFunctionFactory.Create(() => 2, "F2"); + + var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1, f2]); + + Assert.Equal(3, tools.Count); + Assert.IsType(tools[0]); + Assert.Empty(tools[0].AdditionalProperties); + + var s1 = Assert.IsType(tools[1]); + Assert.Equal("F1", s1.Name); + Assert.Null(s1.Namespace); + + var s2 = Assert.IsType(tools[2]); + Assert.Equal("F2", s2.Name); + Assert.Null(s2.Namespace); + } + + [Fact] + public void CreateToolSet_WithNamespaceAndProperties_Roundtrips() + { + var f1 = AIFunctionFactory.Create(() => 1, "F1"); + var props = new Dictionary { ["key"] = "value" }; + + var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1], namespaceName: "ns", toolSearchProperties: props); + + Assert.Equal(2, tools.Count); + + var hostTool = Assert.IsType(tools[0]); + Assert.Same(props, hostTool.AdditionalProperties); + + var s1 = Assert.IsType(tools[1]); + Assert.Equal("ns", s1.Namespace); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs new file mode 100644 index 00000000000..f3a32dc8c84 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs @@ -0,0 +1,38 @@ +// 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 Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedToolSearchToolTests +{ + [Fact] + public void Constructor_Roundtrips() + { + var tool = new HostedToolSearchTool(); + Assert.Equal("tool_search", tool.Name); + Assert.Empty(tool.Description); + Assert.Empty(tool.AdditionalProperties); + Assert.Equal(tool.Name, tool.ToString()); + } + + [Fact] + public void Constructor_AdditionalProperties_Roundtrips() + { + var props = new Dictionary { ["key"] = "value" }; + var tool = new HostedToolSearchTool(props); + + Assert.Equal("tool_search", tool.Name); + Assert.Same(props, tool.AdditionalProperties); + } + + [Fact] + public void Constructor_NullAdditionalProperties_UsesEmpty() + { + var tool = new HostedToolSearchTool(null); + + Assert.Empty(tool.AdditionalProperties); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 9ffd62e72b2..b55e27b4478 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -588,6 +588,59 @@ public void AsOpenAIResponseTool_WithUnknownToolType_ReturnsNull() Assert.Null(result); } + [Fact] + public void AsOpenAIResponseTool_WithHostedToolSearchTool_ProducesValidToolSearchTool() + { + var toolSearchTool = new HostedToolSearchTool(); + + var result = toolSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var json = ModelReaderWriter.Write(result, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("\"type\"", json); + Assert.Contains("tool_search", json); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedToolSearchTool_CachesResult() + { + var result1 = new HostedToolSearchTool().AsOpenAIResponseTool(); + var result2 = new HostedToolSearchTool().AsOpenAIResponseTool(); + + Assert.NotNull(result1); + Assert.Same(result1, result2); + } + + [Fact] + public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclaration_PatchesDeferLoading() + { + var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var searchable = new SearchableAIFunctionDeclaration(inner); + + var result = ((AITool)searchable).AsOpenAIResponseTool(); + + Assert.NotNull(result); + var functionTool = Assert.IsType(result); + var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json); + Assert.Contains("true", json); + } + + [Fact] + public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclarationWithNamespace_PatchesNamespace() + { + var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var searchable = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace"); + + var result = ((AITool)searchable).AsOpenAIResponseTool(); + + Assert.NotNull(result); + var functionTool = Assert.IsType(result); + var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("namespace", json); + Assert.Contains("myNamespace", json); + } + [Fact] public void AsOpenAIResponseTool_WithNullTool_ThrowsArgumentNullException() { From f295d106cdbfb7948a1cb3879da951cf0126b523 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:48:37 +0000 Subject: [PATCH 3/9] Redesign: consolidate tool search into HostedToolSearchTool with DeferredTools/NonDeferredTools - Redesign HostedToolSearchTool with DeferredTools/NonDeferredTools properties - Remove SearchableAIFunctionDeclaration (no longer needed) - Revert DelegatingAIFunctionDeclaration to internal - Update OpenAI provider: use HostedToolSearchTool enable/disable logic for defer_loading - Add ChatOptions parameter to AsOpenAIResponseTool extension method - Use AOT-safe ModelReaderWriter.Read with OpenAIContext.Default - Update API baselines and tests Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- global.json | 2 +- .../DelegatingAIFunctionDeclaration.cs | 2 +- .../SearchableAIFunctionDeclaration.cs | 62 ----------- .../Microsoft.Extensions.AI.Abstractions.json | 72 ++----------- .../Tools/HostedToolSearchTool.cs | 35 ++++++ .../Microsoft.Extensions.AI.OpenAI.json | 2 +- ...icrosoftExtensionsAIResponsesExtensions.cs | 5 +- .../OpenAIJsonContext.cs | 1 - .../OpenAIResponsesChatClient.cs | 41 +++++-- .../SearchableAIFunctionDeclarationTests.cs | 102 ------------------ .../Tools/HostedToolSearchToolTests.cs | 32 ++++++ .../OpenAIConversionTests.cs | 74 +++++++++++-- 12 files changed, 178 insertions(+), 252 deletions(-) delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs diff --git a/global.json b/global.json index 06ad2d78bce..8decbcb016e 100644 --- a/global.json +++ b/global.json @@ -23,4 +23,4 @@ "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.26123.3", "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.26123.3" } -} \ No newline at end of file +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs index 3d509aeff68..38ebcf0ffd9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI; /// /// Provides an optional base class for an that passes through calls to another instance. /// -public class DelegatingAIFunctionDeclaration : AIFunctionDeclaration +internal class DelegatingAIFunctionDeclaration : AIFunctionDeclaration // could be made public in the future if there's demand { /// /// Initializes a new instance of the class as a wrapper around . diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs deleted file mode 100644 index c49b723e5bf..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents an that signals to supporting AI services that deferred -/// loading should be used when tool search is enabled. Only the function's name and description are sent initially; -/// the full JSON schema is loaded on demand by the service when the model selects this tool. -/// -/// -/// This class is a marker/decorator that signals to a supporting provider that the function should be -/// sent with deferred loading (only name and description upfront). Use to create -/// a complete tool list including a and wrapped functions. -/// -[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] -public sealed class SearchableAIFunctionDeclaration : DelegatingAIFunctionDeclaration -{ - /// - /// Initializes a new instance of the class. - /// - /// The represented by this instance. - /// An optional namespace for grouping related tools in the tool search index. - /// is . - public SearchableAIFunctionDeclaration(AIFunctionDeclaration innerFunction, string? namespaceName = null) - : base(innerFunction) - { - Namespace = namespaceName; - } - - /// Gets the optional namespace this function belongs to, for grouping related tools in the tool search index. - public string? Namespace { get; } - - /// - /// Creates a complete tool list with a and the given functions wrapped as . - /// - /// The functions to include as searchable tools. - /// An optional namespace for grouping related tools. - /// Any additional properties to pass to the . - /// A list of instances ready for use in . - /// is . - public static IList CreateToolSet( - IEnumerable functions, - string? namespaceName = null, - IReadOnlyDictionary? toolSearchProperties = null) - { - _ = Throw.IfNull(functions); - - var tools = new List { new HostedToolSearchTool(toolSearchProperties) }; - foreach (var fn in functions) - { - tools.Add(new SearchableAIFunctionDeclaration(fn, namespaceName)); - } - - return tools; - } -} 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 07dbe187606..8647d2e14a3 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 @@ -1619,50 +1619,6 @@ } ] }, - { - "Type": "class Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration : Microsoft.Extensions.AI.AIFunctionDeclaration", - "Stage": "Stable", - "Methods": [ - { - "Member": "Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.DelegatingAIFunctionDeclaration(Microsoft.Extensions.AI.AIFunctionDeclaration innerFunction);", - "Stage": "Stable" - }, - { - "Member": "override object? Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.GetService(System.Type serviceType, object? serviceKey = null);", - "Stage": "Stable" - }, - { - "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.ToString();", - "Stage": "Stable" - } - ], - "Properties": [ - { - "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.AdditionalProperties { get; }", - "Stage": "Stable" - }, - { - "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.Description { get; }", - "Stage": "Stable" - }, - { - "Member": "Microsoft.Extensions.AI.AIFunctionDeclaration Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.InnerFunction { get; }", - "Stage": "Stable" - }, - { - "Member": "override System.Text.Json.JsonElement Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.JsonSchema { get; }", - "Stage": "Stable" - }, - { - "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.Name { get; }", - "Stage": "Stable" - }, - { - "Member": "override System.Text.Json.JsonElement? Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration.ReturnJsonSchema { get; }", - "Stage": "Stable" - } - ] - }, { "Type": "class Microsoft.Extensions.AI.DelegatingChatClient : Microsoft.Extensions.AI.IChatClient, System.IDisposable", "Stage": "Stable", @@ -2367,9 +2323,17 @@ "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.HostedToolSearchTool.AdditionalProperties { get; }", "Stage": "Experimental" }, + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedToolSearchTool.DeferredTools { get; set; }", + "Stage": "Experimental" + }, { "Member": "override string Microsoft.Extensions.AI.HostedToolSearchTool.Name { get; }", "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedToolSearchTool.NonDeferredTools { get; set; }", + "Stage": "Experimental" } ] }, @@ -2950,26 +2914,6 @@ } ] }, - { - "Type": "sealed class Microsoft.Extensions.AI.SearchableAIFunctionDeclaration : Microsoft.Extensions.AI.DelegatingAIFunctionDeclaration", - "Stage": "Experimental", - "Methods": [ - { - "Member": "Microsoft.Extensions.AI.SearchableAIFunctionDeclaration.SearchableAIFunctionDeclaration(Microsoft.Extensions.AI.AIFunctionDeclaration innerFunction, string? namespaceName = null);", - "Stage": "Experimental" - }, - { - "Member": "static System.Collections.Generic.IList Microsoft.Extensions.AI.SearchableAIFunctionDeclaration.CreateToolSet(System.Collections.Generic.IEnumerable functions, string? namespaceName = null, System.Collections.Generic.IReadOnlyDictionary? toolSearchProperties = null);", - "Stage": "Experimental" - } - ], - "Properties": [ - { - "Member": "string? Microsoft.Extensions.AI.SearchableAIFunctionDeclaration.Namespace { get; }", - "Stage": "Experimental" - } - ] - }, { "Type": "static class Microsoft.Extensions.AI.SpeechToTextClientExtensions", "Stage": "Experimental", diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs index 4fd90e06449..50c2465e465 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs @@ -9,8 +9,15 @@ namespace Microsoft.Extensions.AI; /// Represents a hosted tool that can be specified to an AI service to enable it to search for and selectively load tool definitions on demand. /// +/// /// This tool does not itself implement tool search. It is a marker that can be used to inform a service /// that tool search should be enabled, reducing token usage by deferring full tool schema loading until the model requests it. +/// +/// +/// By default, when a is present in the tools list, all other tools are treated +/// as having deferred loading enabled. Use and to control +/// which tools have deferred loading on a per-tool basis. +/// /// [Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] public class HostedToolSearchTool : AITool @@ -35,4 +42,32 @@ public HostedToolSearchTool(IReadOnlyDictionary? additionalProp /// public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; + + /// + /// Gets or sets the list of tool names for which deferred loading should be enabled. + /// + /// + /// + /// The default value is , which enables deferred loading for all tools in the tools list. + /// + /// + /// When non-null, only tools whose names appear in this list will have deferred loading enabled, + /// unless they also appear in . + /// + /// + public IList? DeferredTools { get; set; } + + /// + /// Gets or sets the list of tool names for which deferred loading should be disabled. + /// + /// + /// + /// The default value is , which means no tools are excluded from deferred loading. + /// + /// + /// When non-null, tools whose names appear in this list will not have deferred loading enabled, + /// even if they also appear in . + /// + /// + public IList? NonDeferredTools { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json index 7d0e39492d9..2decf2dc025 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json @@ -100,7 +100,7 @@ "Stage": "Experimental" }, { - "Member": "static OpenAI.Responses.ResponseTool? OpenAI.Responses.MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(this Microsoft.Extensions.AI.AITool tool);", + "Member": "static OpenAI.Responses.ResponseTool? OpenAI.Responses.MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(this Microsoft.Extensions.AI.AITool tool, Microsoft.Extensions.AI.ChatOptions? options = null);", "Stage": "Experimental" } ] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index 419b65aaecc..870cdbbac19 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -26,14 +26,15 @@ public static FunctionTool AsOpenAIResponseTool(this AIFunctionDeclaration funct /// Creates an OpenAI from an . /// The tool to convert. + /// Optional chat options providing context for the conversion. When the tools list includes a , function tools may have deferred loading applied. /// An OpenAI representing or if there is no mapping. /// is . /// /// This method is only able to create s for types /// it's aware of, namely all of those available from the Microsoft.Extensions.AI.Abstractions library. /// - public static ResponseTool? AsOpenAIResponseTool(this AITool tool) => - OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(tool)); + public static ResponseTool? AsOpenAIResponseTool(this AITool tool, ChatOptions? options = null) => + OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(tool), options); /// /// Creates an OpenAI from a . diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs index fcdf957762b..9a040864613 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs @@ -17,7 +17,6 @@ namespace Microsoft.Extensions.AI; WriteIndented = true)] [JsonSerializable(typeof(OpenAIClientExtensions.ToolJson))] [JsonSerializable(typeof(IDictionary))] -[JsonSerializable(typeof(string))] [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(JsonElement))] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 2002f269a5d..e422c5bfc45 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -18,6 +18,7 @@ using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; +using OpenAI; using OpenAI.Responses; #pragma warning disable S1226 // Method parameters, caught exceptions and foreach variables' initial values should not be ignored @@ -51,7 +52,7 @@ private static readonly Func>)); /// Cached deserialized for the tool_search hosted tool. - private static ResponseTool? s_toolSearchResponseTool; + private static ResponseTool? _toolSearchResponseTool; /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -694,19 +695,15 @@ void IDisposable.Dispose() case AIFunctionDeclaration aiFunction: var functionTool = ToResponseTool(aiFunction, options); - if (tool.GetService() is { } searchable) + if (FindToolSearchTool(options) is { } toolSearch && IsDeferredLoading(aiFunction.Name, toolSearch)) { - functionTool.Patch.Set("$.defer_loading"u8, JsonSerializer.SerializeToUtf8Bytes(true).AsSpan()); - if (searchable.Namespace is { } ns) - { - functionTool.Patch.Set("$.namespace"u8, JsonSerializer.SerializeToUtf8Bytes(ns, OpenAIJsonContext.Default.String).AsSpan()); - } + functionTool.Patch.Set("$.defer_loading"u8, "true"u8); } return functionTool; case HostedToolSearchTool: - return s_toolSearchResponseTool ??= ModelReaderWriter.Read(BinaryData.FromString("""{"type": "tool_search"}"""))!; + return _toolSearchResponseTool ??= ModelReaderWriter.Read(BinaryData.FromString("""{"type": "tool_search"}"""), ModelReaderWriterOptions.Json, OpenAIContext.Default)!; case HostedWebSearchTool webSearchTool: return new WebSearchTool @@ -1817,6 +1814,34 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami return null; } + /// Finds the in the options' tools list, if present. + private static HostedToolSearchTool? FindToolSearchTool(ChatOptions? options) + { + if (options?.Tools is { } tools) + { + foreach (AITool t in tools) + { + if (t is HostedToolSearchTool toolSearch) + { + return toolSearch; + } + } + } + + return null; + } + + /// Determines whether the tool with the given name should have deferred loading based on the configuration. + private static bool IsDeferredLoading(string toolName, HostedToolSearchTool toolSearch) + { + if (toolSearch.NonDeferredTools is { } nonDeferred && nonDeferred.Contains(toolName)) + { + return false; + } + + return toolSearch.DeferredTools is not { } deferred || deferred.Contains(toolName); + } + /// Provides an wrapper for a . internal sealed class ResponseToolAITool(ResponseTool tool) : AITool { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs deleted file mode 100644 index 98af5640597..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using Xunit; - -namespace Microsoft.Extensions.AI.Functions; - -public class SearchableAIFunctionDeclarationTests -{ - [Fact] - public void Constructor_NullFunction_ThrowsArgumentNullException() - { - Assert.Throws("innerFunction", () => new SearchableAIFunctionDeclaration(null!)); - } - - [Fact] - public void Constructor_DelegatesToInnerFunction_Properties() - { - var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); - var wrapper = new SearchableAIFunctionDeclaration(inner); - - Assert.Equal(inner.Name, wrapper.Name); - Assert.Equal(inner.Description, wrapper.Description); - Assert.Equal(inner.JsonSchema, wrapper.JsonSchema); - Assert.Equal(inner.ReturnJsonSchema, wrapper.ReturnJsonSchema); - Assert.Same(inner.AdditionalProperties, wrapper.AdditionalProperties); - Assert.Equal(inner.ToString(), wrapper.ToString()); - } - - [Fact] - public void Namespace_DefaultIsNull() - { - var inner = AIFunctionFactory.Create(() => 42); - var wrapper = new SearchableAIFunctionDeclaration(inner); - - Assert.Null(wrapper.Namespace); - } - - [Fact] - public void Namespace_Roundtrips() - { - var inner = AIFunctionFactory.Create(() => 42); - var wrapper = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace"); - - Assert.Equal("myNamespace", wrapper.Namespace); - } - - [Fact] - public void GetService_ReturnsSelf() - { - var inner = AIFunctionFactory.Create(() => 42); - var wrapper = new SearchableAIFunctionDeclaration(inner); - - Assert.Same(wrapper, wrapper.GetService()); - } - - [Fact] - public void CreateToolSet_NullFunctions_Throws() - { - Assert.Throws("functions", () => SearchableAIFunctionDeclaration.CreateToolSet(null!)); - } - - [Fact] - public void CreateToolSet_ReturnsHostedToolSearchToolFirst_ThenWrappedFunctions() - { - var f1 = AIFunctionFactory.Create(() => 1, "F1"); - var f2 = AIFunctionFactory.Create(() => 2, "F2"); - - var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1, f2]); - - Assert.Equal(3, tools.Count); - Assert.IsType(tools[0]); - Assert.Empty(tools[0].AdditionalProperties); - - var s1 = Assert.IsType(tools[1]); - Assert.Equal("F1", s1.Name); - Assert.Null(s1.Namespace); - - var s2 = Assert.IsType(tools[2]); - Assert.Equal("F2", s2.Name); - Assert.Null(s2.Namespace); - } - - [Fact] - public void CreateToolSet_WithNamespaceAndProperties_Roundtrips() - { - var f1 = AIFunctionFactory.Create(() => 1, "F1"); - var props = new Dictionary { ["key"] = "value" }; - - var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1], namespaceName: "ns", toolSearchProperties: props); - - Assert.Equal(2, tools.Count); - - var hostTool = Assert.IsType(tools[0]); - Assert.Same(props, hostTool.AdditionalProperties); - - var s1 = Assert.IsType(tools[1]); - Assert.Equal("ns", s1.Namespace); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs index f3a32dc8c84..24cde84d490 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs @@ -35,4 +35,36 @@ public void Constructor_NullAdditionalProperties_UsesEmpty() Assert.Empty(tool.AdditionalProperties); } + + [Fact] + public void DeferredTools_DefaultIsNull() + { + var tool = new HostedToolSearchTool(); + Assert.Null(tool.DeferredTools); + } + + [Fact] + public void DeferredTools_Roundtrips() + { + var tool = new HostedToolSearchTool(); + var list = new List { "func1", "func2" }; + tool.DeferredTools = list; + Assert.Same(list, tool.DeferredTools); + } + + [Fact] + public void NonDeferredTools_DefaultIsNull() + { + var tool = new HostedToolSearchTool(); + Assert.Null(tool.NonDeferredTools); + } + + [Fact] + public void NonDeferredTools_Roundtrips() + { + var tool = new HostedToolSearchTool(); + var list = new List { "func3" }; + tool.NonDeferredTools = list; + Assert.Same(list, tool.NonDeferredTools); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index b55e27b4478..6a320508322 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -612,12 +612,13 @@ public void AsOpenAIResponseTool_WithHostedToolSearchTool_CachesResult() } [Fact] - public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclaration_PatchesDeferLoading() + public void AsOpenAIResponseTool_AllToolsDeferred_WhenBothListsNull() { - var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); - var searchable = new SearchableAIFunctionDeclaration(inner); + var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var toolSearch = new HostedToolSearchTool(); + var options = new ChatOptions { Tools = [toolSearch, func] }; - var result = ((AITool)searchable).AsOpenAIResponseTool(); + var result = func.AsOpenAIResponseTool(options); Assert.NotNull(result); var functionTool = Assert.IsType(result); @@ -627,18 +628,71 @@ public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclaration_PatchesDefe } [Fact] - public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclarationWithNamespace_PatchesNamespace() + public void AsOpenAIResponseTool_NoDeferLoading_WhenNoHostedToolSearchTool() { - var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); - var searchable = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace"); + var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var options = new ChatOptions { Tools = [func] }; - var result = ((AITool)searchable).AsOpenAIResponseTool(); + var result = func.AsOpenAIResponseTool(options); Assert.NotNull(result); var functionTool = Assert.IsType(result); var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString(); - Assert.Contains("namespace", json); - Assert.Contains("myNamespace", json); + Assert.DoesNotContain("defer_loading", json); + } + + [Fact] + public void AsOpenAIResponseTool_OnlyDeferredToolsGetDeferLoading() + { + var func1 = AIFunctionFactory.Create(() => 1, "Func1"); + var func2 = AIFunctionFactory.Create(() => 2, "Func2"); + var toolSearch = new HostedToolSearchTool { DeferredTools = ["Func1"] }; + var options = new ChatOptions { Tools = [toolSearch, func1, func2] }; + + var result1 = func1.AsOpenAIResponseTool(options); + var result2 = func2.AsOpenAIResponseTool(options); + + var json1 = ModelReaderWriter.Write(result1!, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json1); + + var json2 = ModelReaderWriter.Write(result2!, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json2); + } + + [Fact] + public void AsOpenAIResponseTool_NonDeferredToolsExcluded() + { + var func1 = AIFunctionFactory.Create(() => 1, "Func1"); + var func2 = AIFunctionFactory.Create(() => 2, "Func2"); + var toolSearch = new HostedToolSearchTool { NonDeferredTools = ["Func2"] }; + var options = new ChatOptions { Tools = [toolSearch, func1, func2] }; + + var result1 = func1.AsOpenAIResponseTool(options); + var result2 = func2.AsOpenAIResponseTool(options); + + var json1 = ModelReaderWriter.Write(result1!, ModelReaderWriterOptions.Json).ToString(); + Assert.Contains("defer_loading", json1); + + var json2 = ModelReaderWriter.Write(result2!, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json2); + } + + [Fact] + public void AsOpenAIResponseTool_BothLists_DisableTakesPrecedence() + { + var func = AIFunctionFactory.Create(() => 42, "MyFunc"); + var toolSearch = new HostedToolSearchTool + { + DeferredTools = ["MyFunc"], + NonDeferredTools = ["MyFunc"], + }; + var options = new ChatOptions { Tools = [toolSearch, func] }; + + var result = func.AsOpenAIResponseTool(options); + + Assert.NotNull(result); + var json = ModelReaderWriter.Write(result!, ModelReaderWriterOptions.Json).ToString(); + Assert.DoesNotContain("defer_loading", json); } [Fact] From d6bce96b2fac06d8035919710d5180e7ec7d5a76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:25:13 +0000 Subject: [PATCH 4/9] =?UTF-8?q?Address=20review=20feedback:=20fix=20O(N?= =?UTF-8?q?=C2=B2),=20remove=20json=20baseline=20entry,=20refactor=20ToRes?= =?UTF-8?q?ponseTool=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix O(N²) by finding HostedToolSearchTool once before the tools loop instead of scanning the list for each tool - Remove HostedToolSearchTool from json baseline (experimental types don't need entries) - Refactor ToResponseTool(AITool, ...) to take HostedToolSearchTool? directly instead of extracting from ChatOptions each time - Remove FindToolSearchTool helper method (inlined into callers) Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Microsoft.Extensions.AI.Abstractions.json | 32 ------------------ ...icrosoftExtensionsAIResponsesExtensions.cs | 21 ++++++++++-- .../OpenAIResponsesChatClient.cs | 33 ++++++++----------- 3 files changed, 32 insertions(+), 54 deletions(-) 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 8647d2e14a3..d9f97f58c97 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 @@ -2305,38 +2305,6 @@ } ] }, - { - "Type": "class Microsoft.Extensions.AI.HostedToolSearchTool : Microsoft.Extensions.AI.AITool", - "Stage": "Experimental", - "Methods": [ - { - "Member": "Microsoft.Extensions.AI.HostedToolSearchTool.HostedToolSearchTool();", - "Stage": "Experimental" - }, - { - "Member": "Microsoft.Extensions.AI.HostedToolSearchTool.HostedToolSearchTool(System.Collections.Generic.IReadOnlyDictionary? additionalProperties);", - "Stage": "Experimental" - } - ], - "Properties": [ - { - "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.HostedToolSearchTool.AdditionalProperties { get; }", - "Stage": "Experimental" - }, - { - "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedToolSearchTool.DeferredTools { get; set; }", - "Stage": "Experimental" - }, - { - "Member": "override string Microsoft.Extensions.AI.HostedToolSearchTool.Name { get; }", - "Stage": "Experimental" - }, - { - "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedToolSearchTool.NonDeferredTools { get; set; }", - "Stage": "Experimental" - } - ] - }, { "Type": "sealed class Microsoft.Extensions.AI.HostedVectorStoreContent : Microsoft.Extensions.AI.AIContent", "Stage": "Stable", diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index 870cdbbac19..81817f93679 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -33,8 +33,25 @@ public static FunctionTool AsOpenAIResponseTool(this AIFunctionDeclaration funct /// This method is only able to create s for types /// it's aware of, namely all of those available from the Microsoft.Extensions.AI.Abstractions library. /// - public static ResponseTool? AsOpenAIResponseTool(this AITool tool, ChatOptions? options = null) => - OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(tool), options); + public static ResponseTool? AsOpenAIResponseTool(this AITool tool, ChatOptions? options = null) + { + _ = Throw.IfNull(tool); + + HostedToolSearchTool? toolSearchTool = null; + if (options?.Tools is { } tools) + { + foreach (AITool t in tools) + { + if (t is HostedToolSearchTool tst) + { + toolSearchTool = tst; + break; + } + } + } + + return OpenAIResponsesChatClient.ToResponseTool(tool, toolSearchTool, options); + } /// /// Creates an OpenAI from a . diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index e422c5bfc45..636c145add7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -686,7 +686,7 @@ void IDisposable.Dispose() // Nothing to dispose. } - internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options = null) + internal static ResponseTool? ToResponseTool(AITool tool, HostedToolSearchTool? toolSearchTool, ChatOptions? options) { switch (tool) { @@ -695,7 +695,7 @@ void IDisposable.Dispose() case AIFunctionDeclaration aiFunction: var functionTool = ToResponseTool(aiFunction, options); - if (FindToolSearchTool(options) is { } toolSearch && IsDeferredLoading(aiFunction.Name, toolSearch)) + if (toolSearchTool is not null && IsDeferredLoading(aiFunction.Name, toolSearchTool)) { functionTool.Patch.Set("$.defer_loading"u8, "true"u8); } @@ -920,9 +920,19 @@ private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out // Populate tools if there are any. if (options.Tools is { Count: > 0 } tools) { + HostedToolSearchTool? toolSearchTool = null; foreach (AITool tool in tools) { - if (ToResponseTool(tool, options) is { } responseTool) + if (tool is HostedToolSearchTool tst) + { + toolSearchTool = tst; + break; + } + } + + foreach (AITool tool in tools) + { + if (ToResponseTool(tool, toolSearchTool, options) is { } responseTool) { result.Tools.Add(responseTool); } @@ -1814,23 +1824,6 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami return null; } - /// Finds the in the options' tools list, if present. - private static HostedToolSearchTool? FindToolSearchTool(ChatOptions? options) - { - if (options?.Tools is { } tools) - { - foreach (AITool t in tools) - { - if (t is HostedToolSearchTool toolSearch) - { - return toolSearch; - } - } - } - - return null; - } - /// Determines whether the tool with the given name should have deferred loading based on the configuration. private static bool IsDeferredLoading(string toolName, HostedToolSearchTool toolSearch) { From ed9f47b4deaf952711c6bd9260a46fe155b0e887 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:33:05 +0000 Subject: [PATCH 5/9] Extract shared FindToolSearchTool helper to deduplicate lookup code Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- ...icrosoftExtensionsAIResponsesExtensions.cs | 18 +++---------- .../OpenAIResponsesChatClient.cs | 27 ++++++++++++------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index 81817f93679..f7d8ca88127 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -37,20 +37,10 @@ public static FunctionTool AsOpenAIResponseTool(this AIFunctionDeclaration funct { _ = Throw.IfNull(tool); - HostedToolSearchTool? toolSearchTool = null; - if (options?.Tools is { } tools) - { - foreach (AITool t in tools) - { - if (t is HostedToolSearchTool tst) - { - toolSearchTool = tst; - break; - } - } - } - - return OpenAIResponsesChatClient.ToResponseTool(tool, toolSearchTool, options); + return OpenAIResponsesChatClient.ToResponseTool( + tool, + OpenAIResponsesChatClient.FindToolSearchTool(options?.Tools), + options); } /// diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 636c145add7..d429816a6fb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -920,15 +920,7 @@ private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out // Populate tools if there are any. if (options.Tools is { Count: > 0 } tools) { - HostedToolSearchTool? toolSearchTool = null; - foreach (AITool tool in tools) - { - if (tool is HostedToolSearchTool tst) - { - toolSearchTool = tst; - break; - } - } + HostedToolSearchTool? toolSearchTool = FindToolSearchTool(tools); foreach (AITool tool in tools) { @@ -1835,6 +1827,23 @@ private static bool IsDeferredLoading(string toolName, HostedToolSearchTool tool return toolSearch.DeferredTools is not { } deferred || deferred.Contains(toolName); } + /// Finds the first in the given tools list, if present. + internal static HostedToolSearchTool? FindToolSearchTool(IList? tools) + { + if (tools is not null) + { + foreach (AITool tool in tools) + { + if (tool is HostedToolSearchTool toolSearch) + { + return toolSearch; + } + } + } + + return null; + } + /// Provides an wrapper for a . internal sealed class ResponseToolAITool(ResponseTool tool) : AITool { From b53a6b904168e72f27d11a0b79691982bf72868b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:52:37 +0000 Subject: [PATCH 6/9] Simplify ToResponseTool: add ChatOptions-only overload, make FindToolSearchTool private Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../MicrosoftExtensionsAIResponsesExtensions.cs | 5 +---- .../OpenAIResponsesChatClient.cs | 7 +++++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index f7d8ca88127..b333c349595 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -37,10 +37,7 @@ public static FunctionTool AsOpenAIResponseTool(this AIFunctionDeclaration funct { _ = Throw.IfNull(tool); - return OpenAIResponsesChatClient.ToResponseTool( - tool, - OpenAIResponsesChatClient.FindToolSearchTool(options?.Tools), - options); + return OpenAIResponsesChatClient.ToResponseTool(tool, options); } /// diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index d429816a6fb..c3b1c196564 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -686,7 +686,10 @@ void IDisposable.Dispose() // Nothing to dispose. } - internal static ResponseTool? ToResponseTool(AITool tool, HostedToolSearchTool? toolSearchTool, ChatOptions? options) + internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options) => + ToResponseTool(tool, FindToolSearchTool(options?.Tools), options); + + private static ResponseTool? ToResponseTool(AITool tool, HostedToolSearchTool? toolSearchTool, ChatOptions? options) { switch (tool) { @@ -1828,7 +1831,7 @@ private static bool IsDeferredLoading(string toolName, HostedToolSearchTool tool } /// Finds the first in the given tools list, if present. - internal static HostedToolSearchTool? FindToolSearchTool(IList? tools) + private static HostedToolSearchTool? FindToolSearchTool(IList? tools) { if (tools is not null) { From ead292d051bc1c1864b1eb2e9c9bb8428dbd03cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:04:30 +0000 Subject: [PATCH 7/9] Add unit tests for HostedToolSearchTool JSON serialization and integration test Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../OpenAIResponseClientIntegrationTests.cs | 24 + .../OpenAIResponseClientTests.cs | 484 ++++++++++++++++++ 2 files changed, 508 insertions(+) diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index c98197e0b65..995c9a9a86f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -754,4 +754,28 @@ public async Task ReasoningContent_Streaming_RoundtripsEncryptedContent() }); Assert.Contains("encrypted", ex.Message, StringComparison.OrdinalIgnoreCase); } + + [ConditionalFact] + public async Task UseToolSearch_WithDeferredFunctions() + { + SkipIfNotEnabled(); + + AIFunction getWeather = AIFunctionFactory.Create(() => "Sunny, 72°F", "GetWeather", "Gets the current weather."); + AIFunction getTime = AIFunctionFactory.Create(() => "3:00 PM", "GetTime", "Gets the current time."); + + var response = await ChatClient.GetResponseAsync( + "What's the weather like? Just respond with the weather info, nothing else.", + new() + { + Tools = + [ + new HostedToolSearchTool(), + getWeather, + getTime, + ], + }); + + Assert.NotNull(response); + Assert.NotEmpty(response.Text); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 5e712f22afe..ee98551e575 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -6964,5 +6964,489 @@ public async Task WebSearchTool_Streaming() var textContent = message.Contents.OfType().Single(); Assert.Equal(".NET 10 was officially released.", textContent.Text); } + + [Fact] + public async Task ToolSearchTool_OnlyToolSearch_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = [new HostedToolSearchTool()], + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_AllToolsDeferred_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "function", + "name": "GetWeather", + "description": "Gets the weather.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "GetForecast", + "description": "Gets the forecast.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool(), + AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."), + AIFunctionFactory.Create(() => 42, "GetForecast", "Gets the forecast."), + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_SpecificDeferredTools_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "function", + "name": "GetWeather", + "description": "Gets the weather.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "GetForecast", + "description": "Gets the forecast.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool { DeferredTools = ["GetWeather"] }, + AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."), + AIFunctionFactory.Create(() => 42, "GetForecast", "Gets the forecast."), + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_NonDeferredExclusion_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "function", + "name": "GetWeather", + "description": "Gets the weather.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + }, + { + "type": "function", + "name": "ImportantTool", + "description": "An important tool.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool { NonDeferredTools = ["ImportantTool"] }, + AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."), + AIFunctionFactory.Create(() => 42, "ImportantTool", "An important tool."), + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_BothLists_DisableTakesPrecedence_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "function", + "name": "Func1", + "description": "First function.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + }, + { + "type": "function", + "name": "Func2", + "description": "Second function.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true, + "defer_loading": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool + { + DeferredTools = ["Func1", "Func2"], + NonDeferredTools = ["Func1"], + }, + AIFunctionFactory.Create(() => 1, "Func1", "First function."), + AIFunctionFactory.Create(() => 2, "Func2", "Second function."), + ], + AdditionalProperties = new() { ["strict"] = true }, + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } + + [Fact] + public async Task ToolSearchTool_NoFunctionTools_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "tool_search" + }, + { + "type": "web_search" + } + ] + } + """; + + const string Output = """ + { + "id": "resp_001", + "object": "response", + "created_at": 1741892091, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = + [ + new HostedToolSearchTool(), + new HostedWebSearchTool(), + ], + }); + + Assert.NotNull(response); + Assert.Equal("Hello!", response.Text); + } } From 14dbaca8d18362fdc9eb2792c1ccf4e1617c89ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:51:13 +0000 Subject: [PATCH 8/9] Merge main to resolve conflicts (conflict in OpenAIResponsesChatClient.cs resolved by taking main's version) Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Agent-Logs-Url: https://github.com/dotnet/extensions/sessions/7a29d49e-c422-4fe7-81f4-366bd781b460 --- .github/skills/prepare-release/SKILL.md | 129 ++ .github/skills/release-notes/SKILL.md | 132 ++ .../references/categorize-entries.md | 75 + .../release-notes/references/collect-prs.md | 159 ++ .../references/editorial-rules.md | 143 ++ .../references/experimental-features.md | 115 ++ .../references/format-template.md | 119 ++ .../release-notes/references/package-areas.md | 118 ++ .../release-notes/references/sql-storage.md | 167 +++ azure-pipelines.yml | 2 +- eng/MSBuild/Packaging.targets | 2 +- eng/Version.Details.xml | 212 +-- eng/Versions.props | 230 +-- eng/common/CIBuild.cmd | 2 +- eng/common/SetupNugetSources.ps1 | 90 +- eng/common/SetupNugetSources.sh | 192 ++- eng/common/build.ps1 | 11 +- eng/common/build.sh | 33 +- eng/common/cibuild.sh | 2 +- eng/common/core-templates/job/job.yml | 48 +- eng/common/core-templates/job/onelocbuild.yml | 35 +- .../job/publish-build-assets.yml | 79 +- .../core-templates/job/source-build.yml | 15 +- .../job/source-index-stage1.yml | 47 +- .../core-templates/jobs/codeql-build.yml | 1 - eng/common/core-templates/jobs/jobs.yml | 15 +- .../core-templates/jobs/source-build.yml | 23 +- .../core-templates/post-build/post-build.yml | 26 +- .../steps/cleanup-microbuild.yml | 28 + .../core-templates/steps/generate-sbom.yml | 2 +- .../steps/get-delegation-sas.yml | 11 +- .../steps/install-microbuild.yml | 110 ++ .../core-templates/steps/publish-logs.yml | 8 +- .../core-templates/steps/source-build.yml | 88 +- .../steps/source-index-stage1-publish.yml | 35 + eng/common/cross/arm64/tizen/tizen.patch | 2 +- eng/common/cross/armel/armel.jessie.patch | 43 - eng/common/cross/build-android-rootfs.sh | 49 +- eng/common/cross/build-rootfs.sh | 237 +-- eng/common/cross/install-debs.py | 334 +++++ eng/common/cross/tizen-fetch.sh | 9 +- eng/common/cross/toolchain.cmake | 82 +- eng/common/darc-init.sh | 2 +- eng/common/dotnet.cmd | 7 + eng/common/dotnet.ps1 | 11 + eng/common/dotnet.sh | 26 + eng/common/generate-locproject.ps1 | 49 +- eng/common/native/install-dependencies.sh | 62 + eng/common/post-build/publish-using-darc.ps1 | 9 +- eng/common/post-build/redact-logs.ps1 | 5 +- eng/common/sdk-task.ps1 | 14 +- eng/common/sdk-task.sh | 121 ++ eng/common/sdl/packages.config | 2 +- eng/common/templates-official/job/job.yml | 4 +- .../steps/publish-build-artifacts.yml | 7 +- .../steps/source-index-stage1-publish.yml | 7 + eng/common/templates/job/job.yml | 4 +- .../steps/publish-build-artifacts.yml | 8 +- .../steps/source-index-stage1-publish.yml | 7 + eng/common/templates/steps/vmr-sync.yml | 186 +++ eng/common/templates/vmr-build-pr.yml | 43 + eng/common/tools.ps1 | 71 +- eng/common/tools.sh | 81 +- eng/common/vmr-sync.ps1 | 164 +++ eng/common/vmr-sync.sh | 227 +++ eng/packages/General.props | 2 +- global.json | 8 +- .../HttpLoggingServiceCollectionExtensions.cs | 3 +- .../Logging/IHttpLogEnricher.cs | 3 - .../RequestHeadersLogEnricherOptions.cs | 3 - ...oft.AspNetCore.Diagnostics.Middleware.json | 14 +- .../CHANGELOG.md | 245 ---- .../ChatCompletion/ChatResponseExtensions.cs | 34 +- .../CompatibilitySuppressions.xml | 808 ----------- .../Contents/UriContent.cs | 57 +- .../Files/HostedFileDownloadStream.cs | 34 + .../Functions/AIFunctionDeclaration.cs | 18 +- .../Functions/AIFunctionFactory.cs | 48 +- .../Functions/AIFunctionFactoryOptions.cs | 13 +- .../Microsoft.Extensions.AI.Abstractions.json | 4 +- ...teConversationItemRealtimeClientMessage.cs | 38 + .../CreateResponseRealtimeClientMessage.cs | 125 ++ .../Realtime/DelegatingRealtimeClient.cs | 68 + .../Realtime/ErrorRealtimeServerMessage.cs | 39 + .../Realtime/IRealtimeClient.cs | 33 + .../Realtime/IRealtimeClientSession.cs | 63 + ...tAudioBufferAppendRealtimeClientMessage.cs | 41 + ...tAudioBufferCommitRealtimeClientMessage.cs | 22 + ...AudioTranscriptionRealtimeServerMessage.cs | 58 + .../OutputTextAudioRealtimeServerMessage.cs | 73 + .../Realtime/RealtimeAudioFormat.cs | 33 + .../Realtime/RealtimeClientMessage.cs | 30 + .../Realtime/RealtimeConversationItem.cs | 61 + .../Realtime/RealtimeResponseStatus.cs | 42 + .../Realtime/RealtimeServerMessage.cs | 35 + .../Realtime/RealtimeServerMessageType.cs | 163 +++ .../Realtime/RealtimeSessionKind.cs | 100 ++ .../Realtime/RealtimeSessionOptions.cs | 108 ++ .../ResponseCreatedRealtimeServerMessage.cs | 119 ++ ...ResponseOutputItemRealtimeServerMessage.cs | 54 + .../SessionUpdateRealtimeClientMessage.cs | 42 + .../Realtime/VoiceActivityDetectionOptions.cs | 57 + .../SpeechToText/TranscriptionOptions.cs | 40 + .../DelegatingTextToSpeechClient.cs | 77 + .../TextToSpeech/ITextToSpeechClient.cs | 62 + .../TextToSpeechClientExtensions.cs | 29 + .../TextToSpeechClientMetadata.cs | 44 + .../TextToSpeech/TextToSpeechOptions.cs | 103 ++ .../TextToSpeech/TextToSpeechResponse.cs | 80 + .../TextToSpeechResponseUpdate.cs | 75 + .../TextToSpeechResponseUpdateExtensions.cs | 109 ++ .../TextToSpeechResponseUpdateKind.cs | 105 ++ .../UsageDetails.cs | 92 +- .../Utilities/AIJsonUtilities.Defaults.cs | 7 + ....Extensions.AI.Evaluation.Reporting.csproj | 2 +- .../CSharp/Storage/DiskBasedResponseCache.cs | 14 +- .../CSharp/Storage/DiskBasedResultStore.cs | 40 +- .../CSharp/Utilities/PathValidation.cs | 88 ++ .../TypeScript/azure-devops-report/build.ps1 | 9 +- .../package-lock.json | 513 +++++-- .../PublishAIEvaluationReport/package.json | 2 +- .../TypeScript/package-lock.json | 97 +- .../CHANGELOG.md | 176 --- .../OpenAIClientExtensions.cs | 9 + .../OpenAIFileDownloadStream.cs | 22 - .../OpenAIJsonContext.cs | 1 + .../OpenAIRealtimeClient.cs | 97 ++ .../OpenAIRealtimeClientSession.cs | 1289 +++++++++++++++++ .../OpenAIResponsesChatClient.cs | 80 +- .../OpenAITextToSpeechClient.cs | 137 ++ .../Microsoft.Extensions.AI/CHANGELOG.md | 212 --- .../FunctionInvokingChatClient.cs | 381 +---- .../ChatCompletion/OpenTelemetryChatClient.cs | 11 +- .../OpenTelemetryImageGenerator.cs | 11 +- .../Common/FunctionInvocationHelpers.cs | 41 + .../Common/FunctionInvocationLogger.cs | 55 + .../Common/FunctionInvocationProcessor.cs | 252 ++++ .../Common/OpenTelemetryLog.cs | 17 + .../CompatibilitySuppressions.xml | 109 -- .../OpenTelemetryEmbeddingGenerator.cs | 11 +- .../Files/OpenTelemetryHostedFileClient.cs | 13 +- .../OpenTelemetryConsts.cs | 49 + .../FunctionInvokingRealtimeClient.cs | 131 ++ ...InvokingRealtimeClientBuilderExtensions.cs | 43 + .../FunctionInvokingRealtimeClientSession.cs | 415 ++++++ .../Realtime/LoggingRealtimeClient.cs | 56 + .../LoggingRealtimeClientBuilderExtensions.cs | 59 + .../Realtime/LoggingRealtimeClientSession.cs | 261 ++++ .../Realtime/OpenTelemetryRealtimeClient.cs | 71 + ...elemetryRealtimeClientBuilderExtensions.cs | 79 + .../OpenTelemetryRealtimeClientSession.cs | 1050 ++++++++++++++ .../Realtime/RealtimeClientBuilder.cs | 89 ++ ...meClientBuilderRealtimeClientExtensions.cs | 29 + .../Realtime/RealtimeClientExtensions.cs | 82 ++ .../RealtimeClientSessionExtensions.cs | 82 ++ .../OpenTelemetrySpeechToTextClient.cs | 13 +- .../ConfigureOptionsTextToSpeechClient.cs | 65 + ...ionsTextToSpeechClientBuilderExtensions.cs | 37 + .../TextToSpeech/LoggingTextToSpeechClient.cs | 189 +++ ...gingTextToSpeechClientBuilderExtensions.cs | 57 + .../OpenTelemetryTextToSpeechClient.cs | 355 +++++ ...etryTextToSpeechClientBuilderExtensions.cs | 43 + .../TextToSpeech/TextToSpeechClientBuilder.cs | 82 ++ ...lientBuilderServiceCollectionExtensions.cs | 89 ++ ...ientBuilderTextToSpeechClientExtensions.cs | 27 + .../Microsoft.Extensions.AI/Throw.cs | 15 + .../CHANGELOG.md | 5 - .../CHANGELOG.md | 5 - .../CHANGELOG.md | 5 - .../CHANGELOG.md | 9 - .../Writers/VectorStoreWriter.cs | 8 +- .../Resolver/DnsResolver.cs | 114 +- src/Shared/DiagnosticIds/DiagnosticIds.cs | 1 + .../ChatResponseUpdateExtensionsTests.cs | 45 + .../Contents/UriContentTests.cs | 94 +- .../Files/HostedFileDownloadStreamTests.cs | 31 +- .../Realtime/RealtimeAudioFormatTests.cs | 33 + .../Realtime/RealtimeClientMessageTests.cs | 183 +++ .../Realtime/RealtimeConversationItemTests.cs | 66 + .../Realtime/RealtimeServerMessageTests.cs | 262 ++++ .../Realtime/RealtimeSessionOptionsTests.cs | 139 ++ .../TestJsonSerializerContext.cs | 4 + .../TestRealtimeClientSession.cs | 62 + .../TestTextToSpeechClient.cs | 59 + .../DelegatingTextToSpeechClientTests.cs | 163 +++ .../TextToSpeechClientExtensionsTests.cs | 19 + .../TextToSpeechClientMetadataTests.cs | 29 + .../TextToSpeech/TextToSpeechClientTests.cs | 72 + .../TextToSpeech/TextToSpeechOptionsTests.cs | 216 +++ .../TextToSpeech/TextToSpeechResponseTests.cs | 216 +++ ...xtToSpeechResponseUpdateExtensionsTests.cs | 83 ++ .../TextToSpeechResponseUpdateKindTests.cs | 65 + .../TextToSpeechResponseUpdateTests.cs | 98 ++ .../UsageDetailsTests.cs | 46 + .../DiskBased/PathValidationTests.cs | 486 +++++++ .../TextToSpeechClientIntegrationTests.cs | 138 ++ .../OpenAIRealtimeClientSessionTests.cs | 96 ++ .../OpenAIRealtimeClientTests.cs | 63 + .../OpenAIResponseClientTests.cs | 137 ++ .../OpenAISpeechToTextClientTests.cs | 1 - ...penAITextToSpeechClientIntegrationTests.cs | 12 + .../OpenAITextToSpeechClientTests.cs | 283 ++++ .../FunctionInvokingChatClientTests.cs | 31 +- .../OpenTelemetryChatClientTests.cs | 62 + .../OpenTelemetryEmbeddingGeneratorTests.cs | 44 + .../OpenTelemetryHostedFileClientTests.cs | 41 +- .../Image/OpenTelemetryImageGeneratorTests.cs | 46 + .../Microsoft.Extensions.AI.Tests.csproj | 2 + .../FunctionInvokingRealtimeClientTests.cs | 685 +++++++++ .../Realtime/LoggingRealtimeClientTests.cs | 481 ++++++ .../OpenTelemetryRealtimeClientTests.cs | 1112 ++++++++++++++ .../Realtime/RealtimeClientBuilderTests.cs | 182 +++ .../Realtime/RealtimeClientExtensionsTests.cs | 124 ++ .../RealtimeClientSessionExtensionsTests.cs | 110 ++ .../OpenTelemetrySpeechToTextClientTests.cs | 62 + ...ConfigureOptionsTextToSpeechClientTests.cs | 98 ++ .../LoggingTextToSpeechClientTests.cs | 150 ++ .../OpenTelemetryTextToSpeechClientTests.cs | 192 +++ .../SingletonTextToSpeechClientExtensions.cs | 11 + ...SpeechClientDependencyInjectionPatterns.cs | 178 +++ .../IngestionPipelineTests.cs | 2 +- .../Writers/VectorStoreWriterTests.cs | 5 +- .../Resolver/ResolveAddressesTests.cs | 41 +- 223 files changed, 18851 insertions(+), 3211 deletions(-) create mode 100644 .github/skills/prepare-release/SKILL.md create mode 100644 .github/skills/release-notes/SKILL.md create mode 100644 .github/skills/release-notes/references/categorize-entries.md create mode 100644 .github/skills/release-notes/references/collect-prs.md create mode 100644 .github/skills/release-notes/references/editorial-rules.md create mode 100644 .github/skills/release-notes/references/experimental-features.md create mode 100644 .github/skills/release-notes/references/format-template.md create mode 100644 .github/skills/release-notes/references/package-areas.md create mode 100644 .github/skills/release-notes/references/sql-storage.md create mode 100644 eng/common/core-templates/steps/cleanup-microbuild.yml create mode 100644 eng/common/core-templates/steps/install-microbuild.yml create mode 100644 eng/common/core-templates/steps/source-index-stage1-publish.yml delete mode 100644 eng/common/cross/armel/armel.jessie.patch create mode 100644 eng/common/cross/install-debs.py create mode 100644 eng/common/dotnet.cmd create mode 100644 eng/common/dotnet.ps1 create mode 100644 eng/common/dotnet.sh create mode 100644 eng/common/native/install-dependencies.sh create mode 100644 eng/common/sdk-task.sh create mode 100644 eng/common/templates-official/steps/source-index-stage1-publish.yml create mode 100644 eng/common/templates/steps/source-index-stage1-publish.yml create mode 100644 eng/common/templates/steps/vmr-sync.yml create mode 100644 eng/common/templates/vmr-build-pr.yml create mode 100644 eng/common/vmr-sync.ps1 create mode 100644 eng/common/vmr-sync.sh delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/CreateConversationItemRealtimeClientMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/CreateResponseRealtimeClientMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/DelegatingRealtimeClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ErrorRealtimeServerMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/IRealtimeClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/IRealtimeClientSession.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioBufferAppendRealtimeClientMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioBufferCommitRealtimeClientMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioTranscriptionRealtimeServerMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/OutputTextAudioRealtimeServerMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeAudioFormat.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeClientMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeConversationItem.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeResponseStatus.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeServerMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeServerMessageType.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeSessionKind.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeSessionOptions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ResponseCreatedRealtimeServerMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ResponseOutputItemRealtimeServerMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/SessionUpdateRealtimeClientMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/VoiceActivityDetectionOptions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/TranscriptionOptions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/DelegatingTextToSpeechClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/ITextToSpeechClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechClientExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechClientMetadata.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechOptions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponse.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdate.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdateExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdateKind.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Utilities/PathValidation.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeClientSession.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAITextToSpeechClient.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md create mode 100644 src/Libraries/Microsoft.Extensions.AI/Common/FunctionInvocationHelpers.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Common/FunctionInvocationLogger.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Common/FunctionInvocationProcessor.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Common/OpenTelemetryLog.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI/CompatibilitySuppressions.xml create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClientBuilderExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClientSession.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClientBuilderExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClientSession.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClientBuilderExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClientSession.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientBuilder.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientBuilderRealtimeClientExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientSessionExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/TextToSpeech/ConfigureOptionsTextToSpeechClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/TextToSpeech/ConfigureOptionsTextToSpeechClientBuilderExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/TextToSpeech/LoggingTextToSpeechClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/TextToSpeech/LoggingTextToSpeechClientBuilderExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/TextToSpeech/OpenTelemetryTextToSpeechClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/TextToSpeech/OpenTelemetryTextToSpeechClientBuilderExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilder.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilderServiceCollectionExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilderTextToSpeechClientExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Throw.cs delete mode 100644 src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/CHANGELOG.md delete mode 100644 src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/CHANGELOG.md delete mode 100644 src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/CHANGELOG.md delete mode 100644 src/Libraries/Microsoft.Extensions.DataIngestion/CHANGELOG.md create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeAudioFormatTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeClientMessageTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeConversationItemTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeServerMessageTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeSessionOptionsTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestRealtimeClientSession.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestTextToSpeechClient.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/DelegatingTextToSpeechClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientExtensionsTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientMetadataTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechOptionsTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateExtensionsTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateKindTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/DiskBased/PathValidationTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Integration.Tests/TextToSpeechClientIntegrationTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeClientSessionTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAITextToSpeechClientIntegrationTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAITextToSpeechClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/FunctionInvokingRealtimeClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/LoggingRealtimeClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/OpenTelemetryRealtimeClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientBuilderTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientExtensionsTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientSessionExtensionsTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/ConfigureOptionsTextToSpeechClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/LoggingTextToSpeechClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/OpenTelemetryTextToSpeechClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/SingletonTextToSpeechClientExtensions.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/TextToSpeechClientDependencyInjectionPatterns.cs diff --git a/.github/skills/prepare-release/SKILL.md b/.github/skills/prepare-release/SKILL.md new file mode 100644 index 00000000000..33f30dbff05 --- /dev/null +++ b/.github/skills/prepare-release/SKILL.md @@ -0,0 +1,129 @@ +--- +name: prepare-release +description: Prepares the repository for an internal release branch. Use this when asked to "prepare for a release", "prepare internal release branch", or similar release preparation tasks. +--- + +# Prepare Internal Release Branch + +When preparing a public branch for internal release, apply the following changes: + +## 1. Directory.Build.props + +Add NU1507 warning suppression after the `TestNetCoreTargetFrameworks` PropertyGroup. Internal branches don't use package source mapping due to internal feeds: + +```xml + + + $(NoWarn);NU1507 + +``` + +Insert this new PropertyGroup right after the closing `` that contains `TestNetCoreTargetFrameworks`. + +## 2. NuGet.config + +Remove the entire `` section. This section looks like: + +```xml + + + + + + + + + + +``` + +**Important**: Do NOT add new internal feed sources to NuGet.config - those are managed by Dependency Flow automation and will be added automatically. + +## 3. eng/Versions.props + +Update these two properties (do NOT change any version numbers): + +Change `StabilizePackageVersion` from `false` to `true`: +```xml +true +``` + +Change `DotNetFinalVersionKind` from empty to `release`: +```xml +release +``` + +## 4. eng/pipelines/templates/BuildAndTest.yml + +### Add Private Feeds Credentials Setup + +After the Node.js setup task (the `NodeTool@0` task), add these two tasks to authenticate with private Azure DevOps feeds: + +```yaml + - task: PowerShell@2 + displayName: Setup Private Feeds Credentials + condition: eq(variables['Agent.OS'], 'Windows_NT') + inputs: + filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.ps1 + arguments: -ConfigFile $(Build.SourcesDirectory)/NuGet.config -Password $Env:Token + env: + Token: $(dn-bot-dnceng-artifact-feeds-rw) + + - task: Bash@3 + displayName: Setup Private Feeds Credentials + condition: ne(variables['Agent.OS'], 'Windows_NT') + inputs: + filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.sh + arguments: $(Build.SourcesDirectory)/NuGet.config $Token + env: + Token: $(dn-bot-dnceng-artifact-feeds-rw) +``` + +### Comment Out Integration Tests + +Comment out the integration tests step as they require authentication to private feeds that isn't available during internal release builds: + +```yaml + - ${{ if ne(parameters.skipTests, 'true') }}: + # Skipping integration tests for now as they require authentication to the private feeds + # - script: ${{ parameters.buildScript }} + # -integrationTest + # -configuration ${{ parameters.buildConfig }} + # -warnAsError 1 + # /bl:${{ parameters.repoLogPath }}/integration_tests.binlog + # $(_OfficialBuildIdArgs) + # displayName: Run integration tests +``` + +## 5. azure-pipelines.yml + +Remove the `codecoverage` stage entirely. This is the stage that: +- Has `displayName: CodeCoverage` +- Downloads code coverage reports from build jobs +- Merges and validates combined test coverage +- Contains a `CodeCoverageReport` job + +Also remove the `codecoverage` dependency from the post-build validation's `validateDependsOn` list: + +```yaml +# Remove this conditional dependency block: +- ${{ if eq(parameters.runTests, true) }}: + - codecoverage +``` + +## Files NOT to modify + +- **eng/Version.Details.xml**: Version updates are managed by Dependency Flow automation +- **eng/Versions.props version numbers**: Package versions are managed by Dependency Flow automation +- **NuGet.config feed sources**: Internal darc feeds are added automatically by Dependency Flow + +## Summary + +| File | Action | +|------|--------| +| Directory.Build.props | Add `NU1507` to `NoWarn` in new PropertyGroup | +| NuGet.config | Remove entire `` section | +| eng/Versions.props | Set `StabilizePackageVersion=true`, `DotNetFinalVersionKind=release` | +| eng/pipelines/templates/BuildAndTest.yml | Add private feeds credentials setup tasks, comment out integration tests | +| azure-pipelines.yml | Remove `codecoverage` stage and its post-build dependency | diff --git a/.github/skills/release-notes/SKILL.md b/.github/skills/release-notes/SKILL.md new file mode 100644 index 00000000000..177972c48f9 --- /dev/null +++ b/.github/skills/release-notes/SKILL.md @@ -0,0 +1,132 @@ +--- +name: release-notes +description: 'Draft release notes for a dotnet/extensions release. Gathers merged PRs, assigns them to packages by file path, categorizes by area and impact, tracks experimental API changes, and produces formatted markdown suitable for a GitHub release. Handles both monthly full releases and targeted intra-month patch releases.' +agent: 'agent' +tools: ['github/*', 'sql', 'ask_user'] +--- + +# Release Notes + +Draft release notes for a `dotnet/extensions` release. This skill gathers merged PRs between two tags, maps them to affected packages by examining changed file paths, categorizes entries by area and impact, audits experimental API changes, and produces concise markdown suitable for a GitHub release. + +> **User confirmation required: This skill NEVER publishes a GitHub release without explicit user confirmation.** The user must review and approve the draft before any release is created. + +## Context + +The `dotnet/extensions` repository ships NuGet packages across many functional areas (AI, HTTP Resilience, Diagnostics, Compliance, Telemetry, etc.). Releases come in two forms: + +- **Monthly full releases** — all packages ship together with a minor version bump (e.g. v10.3.0 → v10.4.0) +- **Intra-month patch releases** — a targeted subset of packages ships with a patch version bump (e.g. v10.3.1), typically addressing specific bug fixes or urgent changes + +The repository does not follow Semantic Versioning. Major versions align with annual .NET releases, minor versions increment monthly, and patch versions are for intra-month fixes. + +The repository makes heavy use of `[Experimental]` attributes. Experimental diagnostic IDs are documented in [`docs/list-of-diagnostics.md`](../../docs/list-of-diagnostics.md). Breaking changes to experimental APIs are expected and acceptable. Graduation of experimental APIs to stable is a noteworthy positive event. + +The repository uses `release/` branches (e.g. `release/10.4`) where release tags are associated with commits on those branches. When determining the commit range for a release, ensure the previous and target tags are resolved against the appropriate release branch history. + +## Execution Guidelines + +- **Do not write intermediate files to disk.** Use the **SQL tool** for structured storage and querying (see [references/sql-storage.md](references/sql-storage.md) for schema). +- **Do not run linters, formatters, or validators** on the output. +- **Maximize parallel tool calls.** Fetch multiple PR and issue details in a single response. +- **Package assignment is file-path-driven.** Determine which packages a PR affects by examining which `src/Libraries/{PackageName}/` paths it touches. See [references/package-areas.md](references/package-areas.md) for the mapping. Use `area-*` labels only as a fallback. + +## Process + +Work through each step sequentially. Present findings at each step and get user confirmation before proceeding. + +### Step 1: Determine Release Scope + +The user may provide: +- **Two tags** (previous and target) — use these directly +- **A target tag only** — determine the previous release from `gh release list --repo dotnet/extensions --exclude-drafts` +- **No context** — show the last 5 published releases and ask the user to select + +Once the range is established: + +1. Determine if this is a **full release** (minor version bump) or **patch release** (patch version bump) based on the version numbers. +2. For patch releases, ask the user which packages are included (or infer from the PRs). +3. Get the merge date range for PR collection. + +### Step 2: Collect and Enrich PRs + +Follow [references/collect-prs.md](references/collect-prs.md): + +1. Search for merged PRs in the date range between the two tags. +2. For each PR, fetch the file list and assign packages based on `src/Libraries/{PackageName}/` paths. +3. Enrich with full PR body, reactions, linked issues, and co-author data. +4. Apply exclusion filters (backports, automated version bumps, etc.). +5. Mark remaining PRs as candidates. + +Store all data using the SQL tool. + +### Step 3: Categorize and Group + +Follow [references/categorize-entries.md](references/categorize-entries.md): + +1. **Assign categories**: What's Changed, Documentation Updates, Test Improvements, or Repository Infrastructure Updates. +2. **Group by package area**: For "What's Changed" entries, group under descriptive area headings from [references/package-areas.md](references/package-areas.md). Each area heading must clearly identify the packages it covers. +3. **Order by impact**: Within each area, order entries by impact — breaking changes first, then new features, then bug fixes. +4. **Order areas by activity**: Place the area with the most entries first. + +### Step 4: Audit Experimental API Changes + +Follow [references/experimental-features.md](references/experimental-features.md): + +1. For each candidate PR, **fetch the file list and diff** to identify changes to `[Experimental]` APIs. Do not infer experimental changes from PR titles — always verify against the actual files changed. +2. Classify each change: now stable, new experimental, breaking change to experimental, or removed. +3. Derive the conceptual feature name from the actual types/members affected in the diff. +4. Record in the `experimental_changes` SQL table. +5. Present findings to the user for confirmation. + +### Step 5: Determine Package Versions + +Build the package version information: + +1. For **full releases**: all packages ship at the same version. Note the version number but do not generate a per-package table — it would be repetitive with no value. +2. For **patch releases**: build a table of only the affected packages and their version numbers. +3. Present the version information to the user for confirmation. The user may adjust which packages are included in a patch release. + +### Step 6: Draft Release Notes + +Compose the release notes following [references/format-template.md](references/format-template.md) and [references/editorial-rules.md](references/editorial-rules.md): + +1. **Preamble** — Optionally draft 2–3 sentences summarizing the release theme. Present the preamble options to the user using the `ask_user` tool, offering them the choice of: (a) one of the suggested preambles, (b) writing their own, or (c) skipping the preamble entirely. +2. **Packages in this release** — for patch releases, the table of affected packages and versions from Step 5. For full releases, omit this table (all packages ship at the same version and listing them all adds no value). +3. **Breaking Changes** — stable API breaks only (should be very rare). Include migration guidance. +4. **Experimental API Changes** — from Step 4 results. Group by change type. Omit empty subsections. +5. **What's Changed** — area-grouped entries from Step 3. Omit empty areas. +6. **Documentation Updates** — chronological flat list. +7. **Test Improvements** — chronological flat list. +8. **Repository Infrastructure Updates** — chronological flat list. +9. **Acknowledgements** — new contributors, issue reporters, PR reviewers. +10. **Full Changelog** — link to the GitHub compare view. + +Omit empty sections entirely. + +### Step 7: Review and Finalize + +Present the complete draft to the user: + +1. The full release notes markdown +2. Summary statistics (number of PRs, packages affected, areas covered) +3. Any unresolved items (ambiguous PRs, missing package assignments) + +After the user has reviewed and approved the draft, present the finalization options using the `ask_user` tool: +- **Create draft release** — create a GitHub release in draft state with the notes as the body +- **Save to private gist** — save the draft notes to a private GitHub gist for later use +- **Cancel** — discard the draft without creating anything + +## Edge Cases + +- **PR spans categories**: Categorize by primary intent; read the title and description. +- **PR spans multiple areas**: Place under the most central area; mention cross-cutting nature in the description. +- **Copilot-authored PRs**: If the PR author is Copilot or a bot, check the `copilot_work_started` timeline event for the triggering user, then assignees, then the merger. See [references/editorial-rules.md](references/editorial-rules.md) for the full fallback chain. Never fabricate an attribution — always derive it from the PR data. +- **No breaking changes**: Omit the Breaking Changes section entirely. +- **No experimental changes**: Omit the Experimental API Changes section entirely. +- **No user-facing changes**: If all PRs are documentation, tests, or infrastructure, note this in the release notes. The release still proceeds — this repository ships monthly regardless. +- **Patch release with unclear scope**: Ask the user to confirm which packages are included. +- **No previous release**: If this is the first release under the current versioning scheme, gather all PRs from the beginning of the tag history. +- **Version mismatch**: If the tag version doesn't match the version in source files, flag the discrepancy. +- **Large release (100+ PRs)**: Break the enrichment step into parallel batches. Consider summarizing lower-impact areas more aggressively. +- **Cross-repo changes**: Some PRs may reference issues or changes in other repos (e.g. `dotnet/runtime`). Use full markdown links for cross-repo references. diff --git a/.github/skills/release-notes/references/categorize-entries.md b/.github/skills/release-notes/references/categorize-entries.md new file mode 100644 index 00000000000..6e570167c07 --- /dev/null +++ b/.github/skills/release-notes/references/categorize-entries.md @@ -0,0 +1,75 @@ +# Categorize Entries + +Sort candidate PRs into sections and group them by package area for the release notes. + +## Step 1: Assign categories + +For each candidate PR, assign one of these categories based on the primary intent: + +| Category | Key | Content | +|----------|-----|---------| +| What's Changed | `changed` | Features, bug fixes, API improvements, performance, breaking changes | +| Documentation Updates | `docs` | PRs whose sole purpose is documentation | +| Test Improvements | `tests` | Adding, fixing, or improving tests | +| Repository Infrastructure Updates | `infra` | CI/CD, dependency bumps, version bumps, build system, skills | + +**Decision rules:** +- If a PR modifies files under `src/Libraries/` or `src/Generators/` or `src/Analyzers/`, it is `changed` (even if it also touches docs or tests) +- If a PR **only** modifies files under `docs/`, XML doc comments, or README files, it is `docs` +- If a PR **only** modifies files under `test/`, it is `tests` +- If a PR **only** modifies `eng/`, `scripts/`, `.github/`, CI YAML files, or root config files, it is `infra` +- When a PR spans multiple categories, assign based on primary intent — read the title and description + +Update the SQL record: +```sql +UPDATE prs SET category = '' WHERE number = ; +``` + +## Step 2: Group by package area + +For PRs in the `changed` category, group them under their package area headings using the `pr_packages` table. Each area heading uses the descriptive name from [package-areas.md](package-areas.md). + +**Area heading selection:** +- If a PR affects packages in a single area → place under that area +- If a PR affects packages in multiple areas → place under the area most central to the change, noting the cross-cutting nature in the description if relevant +- If a `changed` PR has no package assignment (rare — e.g. a cross-cutting change to `Directory.Build.props` that affects all packages) → place under a "Cross-Cutting Changes" heading + +**Area ordering in the release notes:** +Order areas by the number of entries (most active area first), then alphabetically for ties. This naturally highlights the areas with the most changes. + +## Step 3: Impact tiering within areas + +Within each area, order entries by impact: + +1. **Breaking changes** (stable API breaks — should be very rare) +2. **Experimental API changes** (graduated, removed, breaking — see [experimental-features.md](experimental-features.md)) +3. **New features and significant improvements** +4. **Bug fixes with community signal** (reported by community members, high reaction count) +5. **Other bug fixes and improvements** + +Use the popularity score from the SQL `prs` + `issues` tables (combined reaction counts) as a tiebreaker within each tier. + +## Step 4: Handle documentation, test, and infrastructure categories + +These categories are **not** grouped by package area. They appear as flat lists in their own sections at the bottom of the release notes: + +- **Documentation Updates** — sorted by merge date +- **Test Improvements** — sorted by merge date +- **Repository Infrastructure Updates** — sorted by merge date + +## Full vs. patch release considerations + +### Full monthly release +- All areas with changes get their own heading +- All four category sections appear (omit empty ones) +- Include the "Experimental API Changes" section if any experimental changes were detected + +### Targeted patch release +- Only the affected areas appear (typically 1–3 areas) +- The preamble explicitly states which packages are included in the patch +- The "Experimental API Changes" section still appears if relevant +- Documentation, test, and infrastructure sections may be shorter or absent + +## Multi-faceted PRs + +A single PR may deliver a feature, fix bugs, AND improve performance. Use the verbatim PR title as the entry description regardless. Read the full PR description, not just the title, to determine the correct category assignment. diff --git a/.github/skills/release-notes/references/collect-prs.md b/.github/skills/release-notes/references/collect-prs.md new file mode 100644 index 00000000000..de21ba88d89 --- /dev/null +++ b/.github/skills/release-notes/references/collect-prs.md @@ -0,0 +1,159 @@ +# Collect and Filter PRs + +Gather all merged PRs between the previous release tag and the target for this release. + +## Determine the commit range + +1. **Previous release tag**: Use `gh release list --repo dotnet/extensions --exclude-drafts --limit 10` to find the most recent published release. If the user specifies a particular previous version, use that instead. +2. **Target**: The user provides a target (commit SHA, branch, or tag). If none is specified, use the `HEAD` of the default branch (`main`). +3. Verify both refs exist: `git rev-parse ` and `git rev-parse `. + +## Search for merged PRs + +### Primary — GitHub MCP server + +Use `search_pull_requests` to find PRs merged in the date range. Keep result sets small to avoid large responses being saved to temp files. + +``` +search_pull_requests( + owner: "dotnet", + repo: "extensions", + query: "is:merged merged:..", + perPage: 30 +) +``` + +Page through results (incrementing `page`) until all PRs are collected. The `start-date` is the merge date of the previous release tag's PR (or the tag's commit date); the `end-date` is the target commit date. + +If the date range yields many results, split by week or use label-scoped queries to keep individual searches small. + +### Fallback — GitHub CLI + +If the MCP server is unavailable: + +```bash +gh pr list --repo dotnet/extensions --state merged \ + --search "merged:.." \ + --limit 500 --json number,title,labels,author,mergedAt,url +``` + +## Assign packages from file paths + +For each PR, fetch the list of changed files: + +``` +pull_request_read( + method: "get_files", + owner: "dotnet", + repo: "extensions", + pullNumber: +) +``` + +Extract package names from file paths matching `src/Libraries/{PackageName}/`. For each matched package, look up the area group from [package-areas.md](package-areas.md). + +**Rules:** +- A PR may affect multiple packages across different areas — record all of them in the `pr_packages` table +- If a PR only touches `test/Libraries/{PackageName}/`, it still maps to that package's area (useful for the "Test Improvements" category) +- If a PR only touches `eng/`, `scripts/`, `.github/`, or root-level files, it has no package assignment — categorize as infrastructure +- If a PR only touches `docs/`, it has no package assignment — categorize as documentation + +**Fallback for ambiguous PRs:** +If a PR has no `src/Libraries/` or `test/Libraries/` file changes but does have `area-*` labels, use those labels to infer the package area. Map `area-Microsoft.Extensions.AI` to the AI area group, etc. + +## Store PR data + +Insert each discovered PR into the `prs` SQL table. See [sql-storage.md](sql-storage.md) for the schema. + +## Enrich PR details + +For each PR, fetch the full body and metadata: + +``` +pull_request_read( + method: "get", + owner: "dotnet", + repo: "extensions", + pullNumber: +) +``` + +Update the `body`, `reactions`, `author_association`, and `labels` columns. Multiple independent PR reads can be issued in parallel. + +Also fetch comments to look for Copilot-generated summaries: + +``` +pull_request_read( + method: "get_comments", + owner: "dotnet", + repo: "extensions", + pullNumber: +) +``` + +Also fetch reviews for the acknowledgements section: + +``` +pull_request_read( + method: "get_reviews", + owner: "dotnet", + repo: "extensions", + pullNumber: +) +``` + +Record each reviewer's username and the PR number. See [editorial-rules.md](editorial-rules.md) for exclusion and sorting rules. + +## Discover linked issues + +Parse the PR body for issue references: +- Closing keywords: `Fixes #1234`, `Closes #1234`, `Resolves #1234` +- Full URL links: `https://github.com/dotnet/extensions/issues/1234` +- Cross-repo references: `dotnet/extensions#1234` + +For each discovered issue, fetch details with `issue_read` and insert into the `issues` table. + +## Deduplicate against prior release + +PRs merged into `main` may include changes that were already included in a prior release via a `release/` branch. The prior release branch may also contain PRs that were merged into it but never covered in that release's notes — those PRs must still be excluded from the current release notes because they shipped in the prior release's packages. + +### Fetch release branches + +Before deduplication, ensure the relevant release branches are available locally: + +1. Identify which local git remote points to `dotnet/extensions` on GitHub (e.g. by checking `git remote -v` for a URL containing `dotnet/extensions`). Use that remote name in subsequent fetch commands. +2. Identify the prior release branch from the previous release tag (e.g. `v10.3.0` → `release/10.3`). +3. Fetch it: `git fetch release/10.3` (using the remote identified in step 1). +4. If the current release also has a release branch (e.g. `release/10.4`), fetch that too. + +### Exclude PRs already shipped + +For each candidate PR, check whether it was already included in any prior release — even if the prior release notes didn't mention it: + +1. **Check against the prior release tag**: `git merge-base --is-ancestor `. If the PR's merge commit is an ancestor of the previous release tag, it shipped in that release — exclude it. +2. **Check against the prior release branch HEAD**: The release branch may have advanced beyond the release tag (e.g. `release/10.3` may contain commits merged after `v10.3.0` was tagged but before `v10.3.1` or the branch was abandoned). Check: `git merge-base --is-ancestor /release/10.3`. If reachable, the PR was part of that release branch's content — exclude it. +3. **Check the prior release notes body**: Fetch the GitHub release for the previous tag and check if the PR number appears in the release notes body. This catches PRs that were explicitly covered. + +> **Why this matters:** A PR can be merged into a `release/` branch, ship in that release's packages, but never appear in that release's notes (e.g. a late-breaking fix). When that PR is later merged into `main`, it appears in the date-range search for the next release. Without branch-aware deduplication, it would be incorrectly included in the new release notes. + +This step is critical and must run before marking PRs as candidates. + +## Exclusion filters + +Before marking PRs as candidates, exclude: +- PRs labeled `backport`, `servicing`, or `NO-MERGE` +- PRs whose title starts with `[release/` or contains `backport` +- PRs that are purely automated version bumps (title matches `Update version to *` and only changes `Directory.Build.props` or version files) + +Mark remaining PRs as candidates: `UPDATE prs SET is_candidate = 1 WHERE ...` + +## Populate co-author data + +For each candidate PR, collect co-authors from **all commits in the PR**, not just the merge commit: + +1. **Fetch the PR's commits** via the pull request commits endpoint (for example, using a `pull_request_read` / PR-scoped `list_commits` method), so it works even if the PR's head branch has been deleted. If needed, also use `get_commit` for the merge commit SHA from the PR details. +2. **Parse `Co-authored-by:` trailers** from every commit message in the PR. These trailers follow the format: `Co-authored-by: Name `. Extract the GitHub username from the email (e.g. `123456+username@users.noreply.github.com` → `username`) or match the name against known GitHub users. +3. **Also check the merge commit** message itself for `Co-authored-by:` trailers, as squash-merged PRs consolidate trailers there. +4. **Check the `copilot_work_started` timeline event** to identify Copilot-assisted PRs where a human delegated the work. + +A common pattern in this repository is a human-authored PR with `Co-authored-by: Copilot <...>` trailers on individual commits — these must be detected to give Copilot co-author attribution. Store all discovered co-authors in the database for use during rendering. diff --git a/.github/skills/release-notes/references/editorial-rules.md b/.github/skills/release-notes/references/editorial-rules.md new file mode 100644 index 00000000000..9efe10704b7 --- /dev/null +++ b/.github/skills/release-notes/references/editorial-rules.md @@ -0,0 +1,143 @@ +# Editorial Rules + +## Tone + +- Remain **objective and factual** — describe what was introduced or changed without editorial judgment + - ✅ `Introduces new APIs for text-to-speech` + - ✅ `Added streaming metrics for time-to-first-chunk and time-per-output-chunk` + - ❌ `Adds significant advancements in AI capabilities` + - ❌ `Previously there was no way to measure streaming latency` +- Avoid superlatives and subjective qualifiers ("significant", "major improvements", "exciting"). Simply state what was added, changed, or fixed. +- When context about the prior state is needed, keep it brief — one clause, not a paragraph — then pivot to the new capability + +## Conciseness + +- **No code samples** in release notes. This repository ships many packages and the release notes should be scannable, not tutorial-length. +- Each entry is a **single bullet point** using the verbatim PR title. +- Link to the PR for details (via `#PR` auto-link). +- If a PR touches multiple concerns, the PR title should capture the primary change. Do not rewrite it. + +## Entry format + +Use this format (GitHub auto-links `#PR` and `@user` in release notes): + +``` +* Description #PR by @author +``` + +For PRs with co-authors (harvested from `Co-authored-by` commit trailers): +``` +* Description #PR by @author (co-authored by @user1 @user2) +``` + +For Dependabot PRs, omit the author: +``` +* Bump actions/checkout from 5.0.0 to 6.0.0 #1234 +``` + +For Copilot-authored PRs, check the `copilot_work_started` timeline event to identify the triggering user. That person becomes the primary author; `@Copilot` becomes a co-author: +``` +* Add trace-level logging for HTTP requests #1234 by @author (co-authored by @Copilot) +``` + +## Entry naming + +- Use the **verbatim PR title** as the entry description. Do not rewrite, rephrase, or summarize PR titles. +- The PR title is the author's chosen description of the change and should be preserved exactly as written. + +## Attribution rules + +> **Critical: Every attribution must be derived from the stored PR data, never fabricated or assumed.** When writing each release note entry, read the `author` field from the `prs` SQL table and the co-author data collected during enrichment. Do not write an `@username` attribution without having that username in the database for that PR. + +- **PR author**: The `user.login` from the PR details — read this from the `prs` table when rendering each entry +- **Co-authors**: Harvest from `Co-authored-by` trailers in **all commits** in the PR (not just the merge commit). Individual commits often carry `Co-authored-by: Copilot <...>` trailers that are not present in the merge commit message. Fetch the PR's commits and parse trailers from each one. For squash-merged PRs, check the squash commit message which consolidates trailers. +- **Copilot-authored PRs**: If the PR author is `Copilot`, `copilot-swe-agent[bot]`, or the PR body mentions "Created from Copilot CLI" / "copilot delegate": + 1. Check the `copilot_work_started` timeline event to identify the triggering user + 2. If found, the triggering user becomes the primary author and `@Copilot` becomes a co-author + 3. If the timeline event is missing, check assignees and the merger — the human who delegated and merged the work is the primary author + 4. As a last resort, attribute to the merger +- **Bots to exclude**: `dependabot[bot]`, `dotnet-maestro[bot]`, `github-actions[bot]`, `copilot-swe-agent[bot]`, and any account ending with `[bot]` + +## Sorting + +Within the **What's Changed** area sections, sort entries by **impact** (see [categorize-entries.md](categorize-entries.md) for the impact tier ordering). Within all other sections (Documentation Updates, Test Improvements, Repository Infrastructure Updates), sort entries by **merge date** (chronological order, oldest first). + +## Category definitions + +### What's Changed +Feature work, bug fixes, API improvements, performance enhancements, and any other user-facing changes. This includes: +- New API surface area +- Bug fixes that affect runtime behavior +- Performance improvements +- Changes that span code + docs (categorize by primary intent) + +### Documentation Updates +PRs whose **sole purpose** is documentation: +- Fixing typos in docs +- Updating XML doc comments (when not part of a functional change) +- README updates + +A PR that changes code AND updates docs belongs in "What's Changed." + +### Test Improvements +PRs focused on test quality or coverage: +- Adding new tests +- Fixing broken or flaky tests +- Test infrastructure improvements + +### Repository Infrastructure Updates +PRs that maintain the development environment: +- Version bumps +- CI/CD workflow changes +- Dependency updates (Dependabot) +- Build system changes +- Copilot instructions and skill updates + +PRs that touch test code should never be categorized as Infrastructure. + +## Acknowledgements section + +Include an acknowledgements section at the bottom of the release notes: + +1. **New contributors** — people making their first contribution in this release +2. **Issue reporters** — community members whose reported issues were resolved in this release, citing the resolving PR +3. **PR reviewers** — single bullet listing all reviewers, sorted by review count (no count shown) + +### Collecting PR reviewers + +For each candidate PR, fetch the reviews: + +``` +pull_request_read( + method: "get_reviews", + owner: "dotnet", + repo: "extensions", + pullNumber: +) +``` + +Collect all users who submitted a review (any state: APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED). Multiple reviews on the same PR by the same user count as one review for that PR. + +**Exclusions — do not list as reviewers:** +- Bot accounts: any account ending with `[bot]`, `Copilot`, `copilot-swe-agent[bot]` +- Users who are already listed as PR authors or co-authors elsewhere in the release notes (they are already acknowledged) +- The PR author themselves (self-reviews) + +**Sorting:** Sort reviewers by the number of distinct PRs they reviewed (descending). Do not show the count — just the sorted order. + +**Format:** A single bullet with all reviewers listed inline: +``` +* @user1 @user2 @user3 reviewed pull requests +``` + +## Inclusion criteria + +Include a feature/fix if: +- It gives users something new or something that works better +- It's a community-requested change (high reaction count on backing issue) +- It changes behavior users need to be aware of + +Exclude: +- Internal refactoring with no user-facing change +- Test-only changes (these go in "Test Improvements") +- Build/infrastructure changes (these go in "Repository Infrastructure Updates") diff --git a/.github/skills/release-notes/references/experimental-features.md b/.github/skills/release-notes/references/experimental-features.md new file mode 100644 index 00000000000..87efeb610c8 --- /dev/null +++ b/.github/skills/release-notes/references/experimental-features.md @@ -0,0 +1,115 @@ +# Experimental Feature Tracking + +The `dotnet/extensions` repository makes heavy use of the `[Experimental]` attribute to mark APIs that are not yet stable. Experimental APIs have their own diagnostic IDs and may undergo breaking changes, graduation to stable, or removal between releases. These changes are noteworthy and deserve dedicated coverage in release notes. + +## Diagnostic ID conventions + +Experimental APIs in this repository use diagnostic IDs documented in [`docs/list-of-diagnostics.md`](../../../docs/list-of-diagnostics.md) under the "Experiments" section. Consult that file for the current list of experimental diagnostic IDs and their descriptions. New diagnostic IDs may be added as new experimental features are introduced. + +## Types of experimental changes + +### Now Stable + +An experimental API has its `[Experimental]` attribute removed, making it a stable part of the public API. This is a positive signal — the API has been validated through preview usage and feedback. + +**How to detect:** +- PR removes `[Experimental("...")]` attribute from types or members +- PR updates the project's experimental diagnostic staging properties (for example, removing the ID from `StageDevDiagnosticId` or related MSBuild properties) in line with [`docs/list-of-diagnostics.md`](../../../docs/list-of-diagnostics.md) +- PR description or title mentions "promote", "graduate", "stabilize", or "remove experimental" +- The corresponding `.json` API baseline file changes a type's `"Stage"` from `"Experimental"` to `"Stable"` + +**How to report:** +Reference the feature by a conceptual name, not individual type names. Do not attribute to an author. +```markdown +* APIs are now stable (previously `EXTEXP0003`) #PR +``` + +### Removed + +An experimental API is removed entirely. This is acceptable under the experimental API contract — consumers who suppressed the diagnostic accepted this possibility. + +**How to detect:** +- PR deletes types or members that were annotated with `[Experimental]` +- PR description mentions "remove" along with experimental type names +- The `.json` API baseline file removes entries that were previously `"Stage": "Experimental"` + +**How to report:** +Reference the feature by a conceptual name, not individual type names. Do not attribute to an author. +```markdown +* experimental APIs removed (was experimental under `MEAI001`) #PR +``` + +### Breaking changes to experimental APIs + +An experimental API changes its signature, behavior, or contracts. These changes are acceptable under the experimental API policy but consumers need to know. + +**How to detect:** +- PR modifies the signature of types/members annotated with `[Experimental]` +- PR changes behavior described in XML docs for experimental types +- PR renames experimental types or changes their namespace + +**How to report:** +```markdown +* : `TypeOrMemberName` signature changed (experimental under `EXTEXP0002`) #PR +``` + +### New experimental APIs + +A new API is introduced with the `[Experimental]` attribute. These are interesting for early adopters. + +**How to detect:** +- PR adds new types or members annotated with `[Experimental]` +- PR introduces a new diagnostic ID +- The `.json` API baseline file adds entries with `"Stage": "Experimental"` + +**How to report:** +```markdown +* New experimental API: (`MEAI002`) #PR +``` + +## Detection strategy + +For each candidate PR, detect experimental API changes using the **PR diff** and the **`run-apichief` skill**. Do not rely on PR titles, descriptions, or labels to determine *what* changed — they can be misleading or incomplete. + +> **Critical: Every experimental change description must be derived from the actual file diff, not inferred from PR titles.** PR titles may use imprecise or overloaded terminology (e.g. "Reduction" could refer to chat reduction or tool reduction — entirely different features). Always fetch and inspect the changed files to determine exactly which types and members were affected. + +### Step-by-step + +1. **Fetch the PR file list** using `pull_request_read` with method `get_files` for every candidate PR. This is mandatory — do not skip it or rely on title-based inference. +2. **Inspect the diff for experimental annotations.** Look for: + - Files adding or removing `[Experimental("...")]` attributes + - Changes to `.json` API baseline files where the `"Stage"` field changes between `"Experimental"` and `"Stable"` + - Deletions of types or members that were previously experimental +3. **Derive the feature name from the actual types affected**, not from the PR title. For example, if the deleted files are `IToolReductionStrategy.cs`, `ToolReducingChatClient.cs`, and `EmbeddingToolReductionStrategy.cs`, the feature name is "Tool Reduction" — even if the PR title says something more generic like "Remove Reduction APIs." +4. **Cross-reference with `run-apichief`** — use the `run-apichief` skill's `emit delta` or `check breaking` commands to compare API baselines between the previous release and the current target. This reveals: + - New experimental types/members added + - Experimental types/members removed + - Experimental types/members that changed stage to Stable + - Signature changes on experimental types/members +5. **Cross-reference `docs/list-of-diagnostics.md`** — check if the PR modifies the diagnostics list, which signals addition or removal of experimental diagnostic IDs. + +Store detected changes in the `experimental_changes` SQL table (see [sql-storage.md](sql-storage.md)). The `description` column must reflect the actual types/members found in the diff, not a summary derived from the PR title. + +## Presentation in release notes + +Experimental feature changes appear in a dedicated section near the top of the release notes, after any stable breaking changes (which should be rare) and before the area-grouped "What's Changed" sections. **Do not include author attributions in this section** — the PRs will still appear with full attribution in the "What's Changed" list. + +Group experimental changes by type: + +```markdown +## Experimental API Changes + +### Now Stable +* HTTP Logging Middleware APIs are now stable (previously `EXTEXP0013`) #7380 + +### New Experimental APIs +* Realtime Client Sessions (`MEAI001`) #7285 + +### Breaking Changes to Experimental APIs +* AI Function Approvals: `FunctionCallApprovalContext` constructor changed (experimental under `MEAI001`) #7350 + +### Removed Experimental APIs +* AI Tool Reduction experimental APIs removed (was experimental under `MEAI001`) #7300 +``` + +Omit subsections that have no entries. diff --git a/.github/skills/release-notes/references/format-template.md b/.github/skills/release-notes/references/format-template.md new file mode 100644 index 00000000000..93f4a18debd --- /dev/null +++ b/.github/skills/release-notes/references/format-template.md @@ -0,0 +1,119 @@ +# Release Notes Format Template + +## Full monthly release + +Use this template when all packages ship together (e.g. v10.3.0 → v10.4.0). + +```markdown +[Optional preamble — 2–3 sentences summarizing the release theme. May be omitted.] + +## Breaking Changes + +[If any stable API breaking changes exist — these should be very rare] + +1. **Description #PR** + * Detail of the break + * Migration guidance + +## Experimental API Changes + +[Grouped by change type — see experimental-features.md] + +### Now Stable +* APIs are now stable (previously `DIAGID`) #PR + +### New Experimental APIs +* New experimental API: (`DIAGID`) #PR + +### Breaking Changes to Experimental APIs +* : `TypeName` signature changed (experimental under `DIAGID`) #PR + +### Removed Experimental APIs +* experimental APIs removed (was experimental under `DIAGID`) #PR + +## What's Changed + +### [Area Name — e.g. "AI"] + +* Description #PR by @author (co-authored by @user1 @Copilot) +* Description #PR by @author + +### [Area Name — e.g. "HTTP Resilience and Diagnostics"] + +* Description #PR by @author + +### [Area Name — e.g. "Diagnostics, Health Checks, and Resource Monitoring"] + +* Description #PR by @author + +## Documentation Updates + +* Description #PR by @author + +## Test Improvements + +* Description #PR by @author + +## Repository Infrastructure Updates + +* Description #PR by @author + +## Acknowledgements + +* @user made their first contribution in #PR +* @user submitted issue #1234 (resolved by #5678) +* @user1 @user2 @user3 reviewed pull requests + +**Full Changelog**: https://github.com/dotnet/extensions/compare/v10.3.0...v10.4.0 +``` + +## Targeted patch release + +Use this template when only a subset of packages ships (e.g. v10.3.1). + +```markdown +[Optional preamble — state which packages are patched and why. Example: "This patch release addresses issues in the AI and HTTP Resilience packages." May be omitted.] + +## Packages in this release + +[Only the patched packages] + +| Package | Version | +|---------|---------| +| Microsoft.Extensions.AI | 10.3.1 | +| Microsoft.Extensions.AI.Abstractions | 10.3.1 | + +## What's Changed + +### [Area Name] + +* Description #PR by @author + +## Acknowledgements + +* @user submitted issue #1234 (resolved by #5678) +* @user1 @user2 reviewed pull requests + +**Full Changelog**: https://github.com/dotnet/extensions/compare/v10.3.0...v10.3.1 +``` + +## Section rules + +1. **Preamble** — optional. If included, summarize the release theme. For patch releases, if included, name the affected packages. Suggest a couple of options to the user and always offer the option of omitting it. +2. **Packages in this release** — for patch releases only. Table of affected packages and versions. Omit for full releases (all packages ship at the same version). +3. **Breaking Changes** — only for stable API breaks (very rare). Omit if none. +4. **Experimental API Changes** — omit if no experimental changes. Omit empty subsections within. +5. **What's Changed** — grouped by area. Order areas by activity (most entries first). Omit areas with no entries. +6. **Documentation Updates** — flat list. Omit if none. +7. **Test Improvements** — flat list. Omit if none. +8. **Repository Infrastructure Updates** — flat list. Omit if none. +9. **Acknowledgements** — always include. Omit empty sub-items. +10. **Full Changelog** — always last. Link to the GitHub compare view. + +## PR and issue references + +Use the format `#number` for PRs and issues in the same repository. GitHub will auto-link these in release notes. Use full markdown links only for cross-repo references: + +- ✅ `#7380` (same repo — GitHub auto-links) +- ✅ `[dotnet/runtime#124264](https://github.com/dotnet/runtime/pull/124264)` (cross-repo) +- ❌ `[#7380](https://github.com/dotnet/extensions/pull/7380)` (unnecessary — same repo) diff --git a/.github/skills/release-notes/references/package-areas.md b/.github/skills/release-notes/references/package-areas.md new file mode 100644 index 00000000000..9d8203951da --- /dev/null +++ b/.github/skills/release-notes/references/package-areas.md @@ -0,0 +1,118 @@ +# Package Area Definitions + +This file maps the libraries in `src/Libraries/` to logical area groups for organizing release notes. Each group name must clearly and unambiguously identify the packages it covers. + +## Area groups + +### AI + +Packages: +- `Microsoft.Extensions.AI` +- `Microsoft.Extensions.AI.Abstractions` +- `Microsoft.Extensions.AI.OpenAI` + +### AI Evaluation + +Packages: +- `Microsoft.Extensions.AI.Evaluation` +- `Microsoft.Extensions.AI.Evaluation.Console` +- `Microsoft.Extensions.AI.Evaluation.NLP` +- `Microsoft.Extensions.AI.Evaluation.Quality` +- `Microsoft.Extensions.AI.Evaluation.Reporting` +- `Microsoft.Extensions.AI.Evaluation.Reporting.Azure` +- `Microsoft.Extensions.AI.Evaluation.Safety` + +### Data Ingestion + +Packages: +- `Microsoft.Extensions.DataIngestion` +- `Microsoft.Extensions.DataIngestion.Abstractions` +- `Microsoft.Extensions.DataIngestion.Markdig` +- `Microsoft.Extensions.DataIngestion.MarkItDown` + +### Diagnostics, Health Checks, and Resource Monitoring + +Packages: +- `Microsoft.Extensions.Diagnostics.ExceptionSummarization` +- `Microsoft.Extensions.Diagnostics.HealthChecks.Common` +- `Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization` +- `Microsoft.Extensions.Diagnostics.Probes` +- `Microsoft.Extensions.Diagnostics.ResourceMonitoring` +- `Microsoft.Extensions.Diagnostics.ResourceMonitoring.Kubernetes` +- `Microsoft.Extensions.Diagnostics.Testing` + +### Compliance, Redaction, and Data Classification + +Packages: +- `Microsoft.Extensions.Compliance.Abstractions` +- `Microsoft.Extensions.Compliance.Redaction` +- `Microsoft.Extensions.Compliance.Testing` + +### HTTP Resilience and Diagnostics + +Packages: +- `Microsoft.Extensions.Http.Resilience` +- `Microsoft.Extensions.Resilience` +- `Microsoft.Extensions.Http.Diagnostics` + +### Telemetry and Observability + +Packages: +- `Microsoft.Extensions.Telemetry` +- `Microsoft.Extensions.Telemetry.Abstractions` + +### ASP.NET Core Extensions + +Packages: +- `Microsoft.AspNetCore.Diagnostics.Middleware` +- `Microsoft.AspNetCore.HeaderParsing` +- `Microsoft.AspNetCore.Testing` +- `Microsoft.AspNetCore.AsyncState` + +### Service Discovery + +Packages: +- `Microsoft.Extensions.ServiceDiscovery` +- `Microsoft.Extensions.ServiceDiscovery.Abstractions` +- `Microsoft.Extensions.ServiceDiscovery.Dns` +- `Microsoft.Extensions.ServiceDiscovery.Yarp` + +### Hosting, Configuration, and Ambient Metadata + +Packages: +- `Microsoft.Extensions.Hosting.Testing` +- `Microsoft.Extensions.Options.Contextual` +- `Microsoft.Extensions.AmbientMetadata.Application` +- `Microsoft.Extensions.AmbientMetadata.Build` + +### Caching + +Packages: +- `Microsoft.Extensions.Caching.Hybrid` + +### Dependency Injection and Object Pooling + +Packages: +- `Microsoft.Extensions.DependencyInjection.AutoActivation` +- `Microsoft.Extensions.ObjectPool.DependencyInjection` + +### Async State + +Packages: +- `Microsoft.Extensions.AsyncState` + +### Time Provider Testing + +Packages: +- `Microsoft.Extensions.TimeProvider.Testing` + +## Assigning PRs to areas + +1. **Primary method — file paths**: Examine the files changed in each PR. Extract package names from paths matching `src/Libraries/{PackageName}/`. Map each package name to its area using the table above. +2. **Fallback — `area-*` labels**: If a PR has no `src/Libraries/` file changes (e.g. infrastructure PRs), check for `area-*` labels and map those to the closest area group. +3. **Multi-area PRs**: A single PR may touch multiple packages in different areas. Assign the PR to all affected areas. When writing the release notes entry, place the PR under the area most central to the change and add a brief cross-reference note for other areas if warranted. +4. **No area match**: PRs that touch only `eng/`, `scripts/`, `.github/`, `docs/`, or `test/` without corresponding `src/Libraries/` changes are infrastructure, documentation, or test PRs — categorize them by type, not by area. + +## Maintaining this file + +When new libraries are added to `src/Libraries/`, update this file to include them in the appropriate area group. If a new area is needed, choose a name that clearly identifies the packages it contains. diff --git a/.github/skills/release-notes/references/sql-storage.md b/.github/skills/release-notes/references/sql-storage.md new file mode 100644 index 00000000000..cbbb03cc390 --- /dev/null +++ b/.github/skills/release-notes/references/sql-storage.md @@ -0,0 +1,167 @@ +# SQL Schema and Patterns + +Use the SQL tool for all structured data storage during the release notes pipeline. Do **not** write intermediate files to disk. + +## Core tables + +```sql +CREATE TABLE prs ( + number INTEGER PRIMARY KEY, + title TEXT, + author TEXT, + author_association TEXT, + labels TEXT, -- comma-separated label names + merged_at TEXT, + body TEXT, + reactions INTEGER DEFAULT 0, + is_candidate INTEGER DEFAULT 0, + category TEXT, -- 'changed', 'docs', 'tests', 'infra' + packages TEXT -- comma-separated package names from file paths +); + +CREATE TABLE pr_packages ( + pr_number INTEGER NOT NULL, + package_name TEXT NOT NULL, + area_group TEXT NOT NULL, + PRIMARY KEY (pr_number, package_name) +); + +CREATE TABLE issues ( + number INTEGER PRIMARY KEY, + title TEXT, + body TEXT, + labels TEXT, + reactions INTEGER DEFAULT 0, + pr_number INTEGER -- the PR that references this issue +); + +CREATE TABLE experimental_changes ( + pr_number INTEGER NOT NULL, + package_name TEXT NOT NULL, + change_type TEXT NOT NULL, -- 'graduated', 'removed', 'breaking', 'added' + diagnostic_id TEXT, -- e.g. 'EXTEXP0001', 'MEAI001' + description TEXT, + PRIMARY KEY (pr_number, package_name, change_type) +); + +CREATE TABLE pr_coauthors ( + pr_number INTEGER NOT NULL, + coauthor TEXT NOT NULL, + PRIMARY KEY (pr_number, coauthor) +); + +CREATE TABLE pr_reviewers ( + pr_number INTEGER NOT NULL, + reviewer TEXT NOT NULL, + PRIMARY KEY (pr_number, reviewer) +); +``` + +## Common queries + +### Find candidate PRs + +```sql +SELECT * FROM prs WHERE is_candidate = 1 ORDER BY merged_at; +``` + +### PRs by area group + +```sql +SELECT DISTINCT p.number, p.title, p.author, p.merged_at, pp.area_group +FROM prs p +JOIN pr_packages pp ON p.number = pp.pr_number +WHERE p.is_candidate = 1 +ORDER BY pp.area_group, p.merged_at; +``` + +### Affected packages (for patch release scope) + +```sql +SELECT DISTINCT package_name, area_group +FROM pr_packages pp +JOIN prs p ON pp.pr_number = p.number +WHERE p.is_candidate = 1 +ORDER BY area_group, package_name; +``` + +### Popularity ranking + +```sql +SELECT p.number, p.title, p.reactions, + COALESCE(SUM(i.reactions), 0) AS issue_reactions, + p.reactions + COALESCE(SUM(i.reactions), 0) AS total_reactions +FROM prs p +LEFT JOIN issues i ON i.pr_number = p.number +WHERE p.is_candidate = 1 +GROUP BY p.number +ORDER BY total_reactions DESC; +``` + +### Experimental feature changes + +```sql +SELECT ec.change_type, ec.package_name, ec.diagnostic_id, ec.description, + p.number, p.title, p.author +FROM experimental_changes ec +JOIN prs p ON ec.pr_number = p.number +ORDER BY ec.change_type, ec.package_name; +``` + +### Category breakdown + +```sql +SELECT category, COUNT(*) as count +FROM prs +WHERE is_candidate = 1 +GROUP BY category; +``` + +### All contributors for acknowledgements + +```sql +SELECT contributor, MIN(pr_number) as first_pr FROM ( + SELECT author AS contributor, number AS pr_number + FROM prs + WHERE is_candidate = 1 + + UNION + + SELECT c.coauthor AS contributor, c.pr_number + FROM pr_coauthors c + JOIN prs p ON p.number = c.pr_number + WHERE p.is_candidate = 1 +) +WHERE contributor NOT LIKE '%[bot]%' + AND contributor != 'Copilot' +GROUP BY contributor +ORDER BY first_pr; +``` + +### PR reviewers for acknowledgements + +```sql +SELECT reviewer, COUNT(DISTINCT pr_number) as review_count +FROM pr_reviewers r +WHERE reviewer NOT LIKE '%[bot]%' + AND reviewer != 'Copilot' + AND reviewer NOT IN (SELECT DISTINCT author FROM prs WHERE is_candidate = 1) + AND reviewer NOT IN ( + SELECT DISTINCT c.coauthor + FROM pr_coauthors c + JOIN prs p ON p.number = c.pr_number + WHERE p.is_candidate = 1 + ) +GROUP BY reviewer +ORDER BY review_count DESC; +``` + +## Usage notes + +- Insert PRs as they are discovered during collection. Update `body`, `reactions`, and `packages` during enrichment. +- Insert into `pr_packages` after file path analysis determines affected packages (see [package-areas.md](package-areas.md)). +- Insert into `pr_coauthors` during enrichment when harvesting `Co-authored-by:` trailers from PR commits. +- Insert into `pr_reviewers` during enrichment when fetching PR reviews. +- Mark candidates with `is_candidate = 1` after filtering. +- Insert `experimental_changes` during the experimental feature audit step. +- Additional PRs can be added to the candidate list manually by number. diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 6caf3896493..80d5564d387 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -145,7 +145,7 @@ extends: template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates parameters: settings: - networkIsolationPolicy: Permissive,CFSClean2 + networkIsolationPolicy: Permissive, CFSClean, CFSClean2 featureFlags: binskimScanAllExtensions: true sdl: diff --git a/eng/MSBuild/Packaging.targets b/eng/MSBuild/Packaging.targets index 32e13e879a6..72f0a412ef2 100644 --- a/eng/MSBuild/Packaging.targets +++ b/eng/MSBuild/Packaging.targets @@ -37,7 +37,7 @@ true - 10.2.0 + 10.4.0 diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 516f874a838..5df4a3b67a9 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,222 +1,222 @@ - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2f124007573374800632d39177cde00ca9fe1ef0 + 19c07820cb72aafc554c3bc8fe3c54010f5123f0 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f736effe82a61eb6f5eba46e4173eae3b7d3dffd + baa6b294e728e6171378b4e8c52e42e7c4d4ed63 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f736effe82a61eb6f5eba46e4173eae3b7d3dffd + baa6b294e728e6171378b4e8c52e42e7c4d4ed63 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f736effe82a61eb6f5eba46e4173eae3b7d3dffd + baa6b294e728e6171378b4e8c52e42e7c4d4ed63 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f736effe82a61eb6f5eba46e4173eae3b7d3dffd + baa6b294e728e6171378b4e8c52e42e7c4d4ed63 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f736effe82a61eb6f5eba46e4173eae3b7d3dffd + baa6b294e728e6171378b4e8c52e42e7c4d4ed63 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f736effe82a61eb6f5eba46e4173eae3b7d3dffd + baa6b294e728e6171378b4e8c52e42e7c4d4ed63 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f736effe82a61eb6f5eba46e4173eae3b7d3dffd + baa6b294e728e6171378b4e8c52e42e7c4d4ed63 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f736effe82a61eb6f5eba46e4173eae3b7d3dffd + baa6b294e728e6171378b4e8c52e42e7c4d4ed63 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f736effe82a61eb6f5eba46e4173eae3b7d3dffd + baa6b294e728e6171378b4e8c52e42e7c4d4ed63 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f736effe82a61eb6f5eba46e4173eae3b7d3dffd + baa6b294e728e6171378b4e8c52e42e7c4d4ed63 - + https://dev.azure.com/dnceng/internal/_git/dotnet-efcore - f838f47ba4ccda655b7f55b2e22984bdc9495720 + 1bea6ab613ce7346af69753850e0dd7eb774bc8a - + https://github.com/dotnet/arcade - 29a2184303379b9840b70e7cdb2faa0f39833b89 + 3907f62e877e105b6196b1bd9c309203d6362a0a - + https://github.com/dotnet/arcade - 29a2184303379b9840b70e7cdb2faa0f39833b89 + 3907f62e877e105b6196b1bd9c309203d6362a0a - + https://github.com/dotnet/arcade - 29a2184303379b9840b70e7cdb2faa0f39833b89 + 3907f62e877e105b6196b1bd9c309203d6362a0a diff --git a/eng/Versions.props b/eng/Versions.props index a2223d61f74..9295b3a07e2 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -33,117 +33,117 @@ --> - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 - 9.0.12 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 + 9.0.14 - 9.0.12 + 9.0.14 - 9.0.0-beta.26123.3 + 10.0.0-beta.26168.1 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 - 10.0.2 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 + 10.0.4 - 10.0.2 + 10.0.4 - 10.0.0-beta.25612.105 + 10.0.0-beta.26119.110 @@ -168,8 +168,8 @@ 8.0.0 8.0.2 8.0.0 - 8.0.23 - 8.0.23 + 8.0.25 + 8.0.25 8.0.0 8.0.1 8.0.1 @@ -186,18 +186,18 @@ 8.0.6 8.0.0 - 8.0.23 - 8.0.23 - 8.0.23 - 8.0.23 - 8.0.23 - 8.0.23 - 8.0.23 - 8.0.23 - 8.0.23 - 8.0.23 + 8.0.25 + 8.0.25 + 8.0.25 + 8.0.25 + 8.0.25 + 8.0.25 + 8.0.25 + 8.0.25 + 8.0.25 + 8.0.25 - 8.0.23 + 8.0.25 " - sed -i.bak "s|$OldDisableValue|$NewDisableValue|" $ConfigFile - echo "Neutralized disablePackageSources entry for '$DisabledSourceName'" - fi - done -fi diff --git a/eng/common/build.ps1 b/eng/common/build.ps1 index 438f9920c43..8cfee107e7a 100644 --- a/eng/common/build.ps1 +++ b/eng/common/build.ps1 @@ -7,6 +7,7 @@ Param( [string] $msbuildEngine = $null, [bool] $warnAsError = $true, [bool] $nodeReuse = $true, + [switch] $buildCheck = $false, [switch][Alias('r')]$restore, [switch] $deployDeps, [switch][Alias('b')]$build, @@ -20,6 +21,7 @@ Param( [switch] $publish, [switch] $clean, [switch][Alias('pb')]$productBuild, + [switch]$fromVMR, [switch][Alias('bl')]$binaryLog, [switch][Alias('nobl')]$excludeCIBinarylog, [switch] $ci, @@ -71,6 +73,9 @@ function Print-Usage() { Write-Host " -msbuildEngine Msbuild engine to use to run build ('dotnet', 'vs', or unspecified)." Write-Host " -excludePrereleaseVS Set to exclude build engines in prerelease versions of Visual Studio" Write-Host " -nativeToolsOnMachine Sets the native tools on machine environment variable (indicating that the script should use native tools on machine)" + Write-Host " -nodeReuse Sets nodereuse msbuild parameter ('true' or 'false')" + Write-Host " -buildCheck Sets /check msbuild parameter" + Write-Host " -fromVMR Set when building from within the VMR" Write-Host "" Write-Host "Command line arguments not listed above are passed thru to msbuild." @@ -97,6 +102,7 @@ function Build { $bl = if ($binaryLog) { '/bl:' + (Join-Path $LogDir 'Build.binlog') } else { '' } $platformArg = if ($platform) { "/p:Platform=$platform" } else { '' } + $check = if ($buildCheck) { '/check' } else { '' } if ($projects) { # Re-assign properties to a new variable because PowerShell doesn't let us append properties directly for unclear reasons. @@ -113,6 +119,7 @@ function Build { MSBuild $toolsetBuildProj ` $bl ` $platformArg ` + $check ` /p:Configuration=$configuration ` /p:RepoRoot=$RepoRoot ` /p:Restore=$restore ` @@ -122,11 +129,13 @@ function Build { /p:Deploy=$deploy ` /p:Test=$test ` /p:Pack=$pack ` - /p:DotNetBuildRepo=$productBuild ` + /p:DotNetBuild=$productBuild ` + /p:DotNetBuildFromVMR=$fromVMR ` /p:IntegrationTest=$integrationTest ` /p:PerformanceTest=$performanceTest ` /p:Sign=$sign ` /p:Publish=$publish ` + /p:RestoreStaticGraphEnableBinaryLogger=$binaryLog ` @properties } diff --git a/eng/common/build.sh b/eng/common/build.sh index ac1ee8620cd..9767bb411a4 100755 --- a/eng/common/build.sh +++ b/eng/common/build.sh @@ -42,6 +42,8 @@ usage() echo " --prepareMachine Prepare machine for CI run, clean up processes after build" echo " --nodeReuse Sets nodereuse msbuild parameter ('true' or 'false')" echo " --warnAsError Sets warnaserror msbuild parameter ('true' or 'false')" + echo " --buildCheck Sets /check msbuild parameter" + echo " --fromVMR Set when building from within the VMR" echo "" echo "Command line arguments not listed above are passed thru to msbuild." echo "Arguments can also be passed in with a single hyphen." @@ -63,6 +65,7 @@ restore=false build=false source_build=false product_build=false +from_vmr=false rebuild=false test=false integration_test=false @@ -76,6 +79,7 @@ clean=false warn_as_error=true node_reuse=true +build_check=false binary_log=false exclude_ci_binary_log=false pipelines_log=false @@ -87,7 +91,7 @@ verbosity='minimal' runtime_source_feed='' runtime_source_feed_key='' -properties='' +properties=() while [[ $# > 0 ]]; do opt="$(echo "${1/#--/-}" | tr "[:upper:]" "[:lower:]")" case "$opt" in @@ -127,19 +131,22 @@ while [[ $# > 0 ]]; do -pack) pack=true ;; - -sourcebuild|-sb) + -sourcebuild|-source-build|-sb) build=true source_build=true product_build=true restore=true pack=true ;; - -productBuild|-pb) + -productbuild|-product-build|-pb) build=true product_build=true restore=true pack=true ;; + -fromvmr|-from-vmr) + from_vmr=true + ;; -test|-t) test=true ;; @@ -173,6 +180,9 @@ while [[ $# > 0 ]]; do node_reuse=$2 shift ;; + -buildcheck) + build_check=true + ;; -runtimesourcefeed) runtime_source_feed=$2 shift @@ -182,7 +192,7 @@ while [[ $# > 0 ]]; do shift ;; *) - properties="$properties $1" + properties+=("$1") ;; esac @@ -216,7 +226,7 @@ function Build { InitializeCustomToolset if [[ ! -z "$projects" ]]; then - properties="$properties /p:Projects=$projects" + properties+=("/p:Projects=$projects") fi local bl="" @@ -224,15 +234,21 @@ function Build { bl="/bl:\"$log_dir/Build.binlog\"" fi + local check="" + if [[ "$build_check" == true ]]; then + check="/check" + fi + MSBuild $_InitializeToolset \ $bl \ + $check \ /p:Configuration=$configuration \ /p:RepoRoot="$repo_root" \ /p:Restore=$restore \ /p:Build=$build \ - /p:DotNetBuildRepo=$product_build \ - /p:ArcadeBuildFromSource=$source_build \ + /p:DotNetBuild=$product_build \ /p:DotNetBuildSourceOnly=$source_build \ + /p:DotNetBuildFromVMR=$from_vmr \ /p:Rebuild=$rebuild \ /p:Test=$test \ /p:Pack=$pack \ @@ -240,7 +256,8 @@ function Build { /p:PerformanceTest=$performance_test \ /p:Sign=$sign \ /p:Publish=$publish \ - $properties + /p:RestoreStaticGraphEnableBinaryLogger=$binary_log \ + ${properties[@]+"${properties[@]}"} ExitWithExitCode 0 } diff --git a/eng/common/cibuild.sh b/eng/common/cibuild.sh index 1a02c0dec8f..66e3b0ac61c 100755 --- a/eng/common/cibuild.sh +++ b/eng/common/cibuild.sh @@ -13,4 +13,4 @@ while [[ -h $source ]]; do done scriptroot="$( cd -P "$( dirname "$source" )" && pwd )" -. "$scriptroot/build.sh" --restore --build --test --pack --publish --ci $@ \ No newline at end of file +. "$scriptroot/build.sh" --restore --build --test --pack --publish --ci $@ diff --git a/eng/common/core-templates/job/job.yml b/eng/common/core-templates/job/job.yml index 8da43d3b583..5ce51840619 100644 --- a/eng/common/core-templates/job/job.yml +++ b/eng/common/core-templates/job/job.yml @@ -19,11 +19,11 @@ parameters: # publishing defaults artifacts: '' enableMicrobuild: false + enableMicrobuildForMacAndLinux: false microbuildUseESRP: true enablePublishBuildArtifacts: false enablePublishBuildAssets: false enablePublishTestResults: false - enablePublishUsingPipelines: false enableBuildRetry: false mergeTestResults: false testRunTitle: '' @@ -74,9 +74,6 @@ jobs: - ${{ if ne(parameters.enableTelemetry, 'false') }}: - name: DOTNET_CLI_TELEMETRY_PROFILE value: '$(Build.Repository.Uri)' - - ${{ if eq(parameters.enableRichCodeNavigation, 'true') }}: - - name: EnableRichCodeNavigation - value: 'true' # Retry signature validation up to three times, waiting 2 seconds between attempts. # See https://learn.microsoft.com/en-us/nuget/reference/errors-and-warnings/nu3028#retry-untrusted-root-failures - name: NUGET_EXPERIMENTAL_CHAIN_BUILD_RETRY_POLICY @@ -128,23 +125,12 @@ jobs: - ${{ preStep }} - ${{ if and(eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: - - ${{ if eq(parameters.enableMicrobuild, 'true') }}: - - task: MicroBuildSigningPlugin@4 - displayName: Install MicroBuild plugin - inputs: - signType: $(_SignType) - zipSources: false - feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json - ${{ if eq(parameters.microbuildUseESRP, true) }}: - ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: - ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea - ${{ else }}: - ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca - env: - TeamName: $(_TeamName) - MicroBuildOutputFolderOverride: '$(Agent.TempDirectory)' + - template: /eng/common/core-templates/steps/install-microbuild.yml + parameters: + enableMicrobuild: ${{ parameters.enableMicrobuild }} + enableMicrobuildForMacAndLinux: ${{ parameters.enableMicrobuildForMacAndLinux }} + microbuildUseESRP: ${{ parameters.microbuildUseESRP }} continueOnError: ${{ parameters.continueOnError }} - condition: and(succeeded(), in(variables['_SignType'], 'real', 'test'), eq(variables['Agent.Os'], 'Windows_NT')) - ${{ if and(eq(parameters.runAsPublic, 'false'), eq(variables['System.TeamProject'], 'internal')) }}: - task: NuGetAuthenticate@1 @@ -160,27 +146,15 @@ jobs: - ${{ each step in parameters.steps }}: - ${{ step }} - - ${{ if eq(parameters.enableRichCodeNavigation, true) }}: - - task: RichCodeNavIndexer@0 - displayName: RichCodeNav Upload - inputs: - languages: ${{ coalesce(parameters.richCodeNavigationLanguage, 'csharp') }} - environment: ${{ coalesce(parameters.richCodeNavigationEnvironment, 'internal') }} - richNavLogOutputDirectory: $(System.DefaultWorkingDirectory)/artifacts/bin - uploadRichNavArtifacts: ${{ coalesce(parameters.richCodeNavigationUploadArtifacts, false) }} - continueOnError: true - - ${{ each step in parameters.componentGovernanceSteps }}: - ${{ step }} - - ${{ if eq(parameters.enableMicrobuild, 'true') }}: - - ${{ if and(eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: - - task: MicroBuildCleanup@1 - displayName: Execute Microbuild cleanup tasks - condition: and(always(), in(variables['_SignType'], 'real', 'test'), eq(variables['Agent.Os'], 'Windows_NT')) + - ${{ if and(eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: + - template: /eng/common/core-templates/steps/cleanup-microbuild.yml + parameters: + enableMicrobuild: ${{ parameters.enableMicrobuild }} + enableMicrobuildForMacAndLinux: ${{ parameters.enableMicrobuildForMacAndLinux }} continueOnError: ${{ parameters.continueOnError }} - env: - TeamName: $(_TeamName) # Publish test results - ${{ if or(and(eq(parameters.enablePublishTestResults, 'true'), eq(parameters.testResultsFormat, '')), eq(parameters.testResultsFormat, 'xunit')) }}: diff --git a/eng/common/core-templates/job/onelocbuild.yml b/eng/common/core-templates/job/onelocbuild.yml index edefa789d36..c5788829a87 100644 --- a/eng/common/core-templates/job/onelocbuild.yml +++ b/eng/common/core-templates/job/onelocbuild.yml @@ -4,7 +4,7 @@ parameters: # Optional: A defined YAML pool - https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=vsts&tabs=schema#pool pool: '' - + CeapexPat: $(dn-bot-ceapex-package-r) # PAT for the loc AzDO instance https://dev.azure.com/ceapex GithubPat: $(BotAccount-dotnet-bot-repo-PAT) @@ -27,7 +27,7 @@ parameters: is1ESPipeline: '' jobs: - job: OneLocBuild${{ parameters.JobNameSuffix }} - + dependsOn: ${{ parameters.dependsOn }} displayName: OneLocBuild${{ parameters.JobNameSuffix }} @@ -86,8 +86,7 @@ jobs: isAutoCompletePrSelected: ${{ parameters.AutoCompletePr }} ${{ if eq(parameters.CreatePr, true) }}: isUseLfLineEndingsSelected: ${{ parameters.UseLfLineEndings }} - ${{ if eq(parameters.RepoType, 'gitHub') }}: - isShouldReusePrSelected: ${{ parameters.ReusePr }} + isShouldReusePrSelected: ${{ parameters.ReusePr }} packageSourceAuth: patAuth patVariable: ${{ parameters.CeapexPat }} ${{ if eq(parameters.RepoType, 'gitHub') }}: @@ -100,22 +99,20 @@ jobs: mirrorBranch: ${{ parameters.MirrorBranch }} condition: ${{ parameters.condition }} - - template: /eng/common/core-templates/steps/publish-build-artifacts.yml - parameters: - is1ESPipeline: ${{ parameters.is1ESPipeline }} - args: - displayName: Publish Localization Files - pathToPublish: '$(Build.ArtifactStagingDirectory)/loc' - publishLocation: Container - artifactName: Loc - condition: ${{ parameters.condition }} + # Copy the locProject.json to the root of the Loc directory, then publish a pipeline artifact + - task: CopyFiles@2 + displayName: Copy LocProject.json + inputs: + SourceFolder: '$(System.DefaultWorkingDirectory)/eng/Localize/' + Contents: 'LocProject.json' + TargetFolder: '$(Build.ArtifactStagingDirectory)/loc' + condition: ${{ parameters.condition }} - - template: /eng/common/core-templates/steps/publish-build-artifacts.yml + - template: /eng/common/core-templates/steps/publish-pipeline-artifacts.yml parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} args: - displayName: Publish LocProject.json - pathToPublish: '$(System.DefaultWorkingDirectory)/eng/Localize/' - publishLocation: Container - artifactName: Loc - condition: ${{ parameters.condition }} \ No newline at end of file + targetPath: '$(Build.ArtifactStagingDirectory)/loc' + artifactName: 'Loc' + displayName: 'Publish Localization Files' + condition: ${{ parameters.condition }} diff --git a/eng/common/core-templates/job/publish-build-assets.yml b/eng/common/core-templates/job/publish-build-assets.yml index 3cb20fb5041..b955fac6e13 100644 --- a/eng/common/core-templates/job/publish-build-assets.yml +++ b/eng/common/core-templates/job/publish-build-assets.yml @@ -20,9 +20,6 @@ parameters: # if 'true', the build won't run any of the internal only steps, even if it is running in non-public projects. runAsPublic: false - # Optional: whether the build's artifacts will be published using release pipelines or direct feed publishing - publishUsingPipelines: false - # Optional: whether the build's artifacts will be published using release pipelines or direct feed publishing publishAssetsImmediately: false @@ -32,6 +29,15 @@ parameters: is1ESPipeline: '' + # Optional: 🌤️ or not the build has assets it wants to publish to BAR + isAssetlessBuild: false + + # Optional, publishing version + publishingVersion: 3 + + # Optional: A minimatch pattern for the asset manifests to publish to BAR + assetManifestsPattern: '*/manifests/**/*.xml' + repositoryAlias: self officialBuildId: '' @@ -84,18 +90,44 @@ jobs: - checkout: ${{ parameters.repositoryAlias }} fetchDepth: 3 clean: true - - - task: DownloadBuildArtifacts@0 - displayName: Download artifact - inputs: - artifactName: AssetManifests - downloadPath: '$(Build.StagingDirectory)/Download' - checkDownloadedFiles: true - condition: ${{ parameters.condition }} - continueOnError: ${{ parameters.continueOnError }} + + - ${{ if eq(parameters.isAssetlessBuild, 'false') }}: + - ${{ if eq(parameters.publishingVersion, 3) }}: + - task: DownloadPipelineArtifact@2 + displayName: Download Asset Manifests + inputs: + artifactName: AssetManifests + targetPath: '$(Build.StagingDirectory)/AssetManifests' + condition: ${{ parameters.condition }} + continueOnError: ${{ parameters.continueOnError }} + - ${{ if eq(parameters.publishingVersion, 4) }}: + - task: DownloadPipelineArtifact@2 + displayName: Download V4 asset manifests + inputs: + itemPattern: '*/manifests/**/*.xml' + targetPath: '$(Build.StagingDirectory)/AllAssetManifests' + condition: ${{ parameters.condition }} + continueOnError: ${{ parameters.continueOnError }} + - task: CopyFiles@2 + displayName: Copy V4 asset manifests to AssetManifests + inputs: + SourceFolder: '$(Build.StagingDirectory)/AllAssetManifests' + Contents: ${{ parameters.assetManifestsPattern }} + TargetFolder: '$(Build.StagingDirectory)/AssetManifests' + flattenFolders: true + condition: ${{ parameters.condition }} + continueOnError: ${{ parameters.continueOnError }} - task: NuGetAuthenticate@1 + # Populate internal runtime variables. + - template: /eng/common/templates/steps/enable-internal-sources.yml + ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: + parameters: + legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) + + - template: /eng/common/templates/steps/enable-internal-runtimes.yml + - task: AzureCLI@2 displayName: Publish Build Assets inputs: @@ -104,10 +136,13 @@ jobs: scriptLocation: scriptPath scriptPath: $(System.DefaultWorkingDirectory)/eng/common/sdk-task.ps1 arguments: -task PublishBuildAssets -restore -msbuildEngine dotnet - /p:ManifestsPath='$(Build.StagingDirectory)/Download/AssetManifests' + /p:ManifestsPath='$(Build.StagingDirectory)/AssetManifests' + /p:IsAssetlessBuild=${{ parameters.isAssetlessBuild }} /p:MaestroApiEndpoint=https://maestro.dot.net - /p:PublishUsingPipelines=${{ parameters.publishUsingPipelines }} /p:OfficialBuildId=$(OfficialBuildId) + -runtimeSourceFeed https://ci.dot.net/internal + -runtimeSourceFeedKey '$(dotnetbuilds-internal-container-read-token-base64)' + condition: ${{ parameters.condition }} continueOnError: ${{ parameters.continueOnError }} @@ -129,6 +164,17 @@ jobs: Copy-Item -Path $symbolExclusionfile -Destination "$(Build.StagingDirectory)/ReleaseConfigs" } + - ${{ if eq(parameters.publishingVersion, 4) }}: + - template: /eng/common/core-templates/steps/publish-pipeline-artifacts.yml + parameters: + is1ESPipeline: ${{ parameters.is1ESPipeline }} + args: + targetPath: '$(Build.ArtifactStagingDirectory)/MergedManifest.xml' + artifactName: AssetManifests + displayName: 'Publish Merged Manifest' + retryCountOnTaskFailure: 10 # for any logs being locked + sbomEnabled: false # we don't need SBOM for logs + - template: /eng/common/core-templates/steps/publish-build-artifacts.yml parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} @@ -138,7 +184,7 @@ jobs: publishLocation: Container artifactName: ReleaseConfigs - - ${{ if eq(parameters.publishAssetsImmediately, 'true') }}: + - ${{ if or(eq(parameters.publishAssetsImmediately, 'true'), eq(parameters.isAssetlessBuild, 'true')) }}: - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml parameters: BARBuildId: ${{ parameters.BARBuildId }} @@ -164,6 +210,9 @@ jobs: -WaitPublishingFinish true -ArtifactsPublishingAdditionalParameters '${{ parameters.artifactsPublishingAdditionalParameters }}' -SymbolPublishingAdditionalParameters '${{ parameters.symbolPublishingAdditionalParameters }}' + -SkipAssetsPublishing '${{ parameters.isAssetlessBuild }}' + -runtimeSourceFeed https://ci.dot.net/internal + -runtimeSourceFeedKey '$(dotnetbuilds-internal-container-read-token-base64)' - ${{ if eq(parameters.enablePublishBuildArtifacts, 'true') }}: - template: /eng/common/core-templates/steps/publish-logs.yml diff --git a/eng/common/core-templates/job/source-build.yml b/eng/common/core-templates/job/source-build.yml index d943748ac10..1997c2ae00d 100644 --- a/eng/common/core-templates/job/source-build.yml +++ b/eng/common/core-templates/job/source-build.yml @@ -12,9 +12,10 @@ parameters: # The name of the job. This is included in the job ID. # targetRID: '' # The name of the target RID to use, instead of the one auto-detected by Arcade. - # nonPortable: false + # portableBuild: false # Enables non-portable mode. This means a more specific RID (e.g. fedora.32-x64 rather than - # linux-x64), and compiling against distro-provided packages rather than portable ones. + # linux-x64), and compiling against distro-provided packages rather than portable ones. The + # default is portable mode. # skipPublishValidation: false # Disables publishing validation. By default, a check is performed to ensure no packages are # published by source-build. @@ -33,9 +34,6 @@ parameters: # container and pool. platform: {} - # Optional list of directories to ignore for component governance scans. - componentGovernanceIgnoreDirectories: [] - is1ESPipeline: '' # If set to true and running on a non-public project, @@ -62,7 +60,7 @@ jobs: pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore-Svc-Public' ), False, 'NetCore-Public')] - demands: ImageOverride -equals build.ubuntu.2004.amd64 + demands: ImageOverride -equals build.azurelinux.3.amd64.open ${{ if eq(variables['System.TeamProject'], 'internal') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore1ESPool-Svc-Internal'), False, 'NetCore1ESPool-Internal')] image: build.azurelinux.3.amd64 @@ -71,10 +69,10 @@ jobs: pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore-Svc-Public' ), False, 'NetCore-Public')] - demands: ImageOverride -equals Build.Ubuntu.2204.Amd64.Open + demands: ImageOverride -equals build.azurelinux.3.amd64.open ${{ if eq(variables['System.TeamProject'], 'internal') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore1ESPool-Svc-Internal'), False, 'NetCore1ESPool-Internal')] - demands: ImageOverride -equals Build.Ubuntu.2204.Amd64 + demands: ImageOverride -equals build.azurelinux.3.amd64 ${{ if ne(parameters.platform.pool, '') }}: pool: ${{ parameters.platform.pool }} @@ -96,4 +94,3 @@ jobs: parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} platform: ${{ parameters.platform }} - componentGovernanceIgnoreDirectories: ${{ parameters.componentGovernanceIgnoreDirectories }} diff --git a/eng/common/core-templates/job/source-index-stage1.yml b/eng/common/core-templates/job/source-index-stage1.yml index ddf8c2e00d8..76baf5c2725 100644 --- a/eng/common/core-templates/job/source-index-stage1.yml +++ b/eng/common/core-templates/job/source-index-stage1.yml @@ -1,8 +1,5 @@ parameters: runAsPublic: false - sourceIndexUploadPackageVersion: 2.0.0-20250425.2 - sourceIndexProcessBinlogPackageVersion: 1.0.1-20250425.2 - sourceIndexPackageSource: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json sourceIndexBuildCommand: powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "eng/common/build.ps1 -restore -build -binarylog -ci" preSteps: [] binlogPath: artifacts/log/Debug/Build.binlog @@ -16,12 +13,6 @@ jobs: dependsOn: ${{ parameters.dependsOn }} condition: ${{ parameters.condition }} variables: - - name: SourceIndexUploadPackageVersion - value: ${{ parameters.sourceIndexUploadPackageVersion }} - - name: SourceIndexProcessBinlogPackageVersion - value: ${{ parameters.sourceIndexProcessBinlogPackageVersion }} - - name: SourceIndexPackageSource - value: ${{ parameters.sourceIndexPackageSource }} - name: BinlogPath value: ${{ parameters.binlogPath }} - template: /eng/common/core-templates/variables/pool-providers.yml @@ -34,12 +25,10 @@ jobs: pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: name: $(DncEngPublicBuildPool) - image: 1es-windows-2022-open - os: windows + image: windows.vs2026preview.scout.amd64.open ${{ if eq(variables['System.TeamProject'], 'internal') }}: name: $(DncEngInternalBuildPool) - image: 1es-windows-2022 - os: windows + image: windows.vs2026preview.scout.amd64 steps: - ${{ if eq(parameters.is1ESPipeline, '') }}: @@ -47,35 +36,9 @@ jobs: - ${{ each preStep in parameters.preSteps }}: - ${{ preStep }} - - - task: UseDotNet@2 - displayName: Use .NET 8 SDK - inputs: - packageType: sdk - version: 8.0.x - installationPath: $(Agent.TempDirectory)/dotnet - workingDirectory: $(Agent.TempDirectory) - - - script: | - $(Agent.TempDirectory)/dotnet/dotnet tool install BinLogToSln --version $(sourceIndexProcessBinlogPackageVersion) --add-source $(SourceIndexPackageSource) --tool-path $(Agent.TempDirectory)/.source-index/tools - $(Agent.TempDirectory)/dotnet/dotnet tool install UploadIndexStage1 --version $(sourceIndexUploadPackageVersion) --add-source $(SourceIndexPackageSource) --tool-path $(Agent.TempDirectory)/.source-index/tools - displayName: Download Tools - # Set working directory to temp directory so 'dotnet' doesn't try to use global.json and use the repo's sdk. - workingDirectory: $(Agent.TempDirectory) - - script: ${{ parameters.sourceIndexBuildCommand }} displayName: Build Repository - - script: $(Agent.TempDirectory)/.source-index/tools/BinLogToSln -i $(BinlogPath) -r $(System.DefaultWorkingDirectory) -n $(Build.Repository.Name) -o .source-index/stage1output - displayName: Process Binlog into indexable sln - - - ${{ if and(eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: - - task: AzureCLI@2 - displayName: Log in to Azure and upload stage1 artifacts to source index - inputs: - azureSubscription: 'SourceDotNet Stage1 Publish' - addSpnToEnvironment: true - scriptType: 'ps' - scriptLocation: 'inlineScript' - inlineScript: | - $(Agent.TempDirectory)/.source-index/tools/UploadIndexStage1 -i .source-index/stage1output -n $(Build.Repository.Name) -s netsourceindexstage1 -b stage1 + - template: /eng/common/core-templates/steps/source-index-stage1-publish.yml + parameters: + binLogPath: ${{ parameters.binLogPath }} diff --git a/eng/common/core-templates/jobs/codeql-build.yml b/eng/common/core-templates/jobs/codeql-build.yml index 4571a7864df..dbc14ac580a 100644 --- a/eng/common/core-templates/jobs/codeql-build.yml +++ b/eng/common/core-templates/jobs/codeql-build.yml @@ -15,7 +15,6 @@ jobs: enablePublishBuildArtifacts: false enablePublishTestResults: false enablePublishBuildAssets: false - enablePublishUsingPipelines: false enableTelemetry: true variables: diff --git a/eng/common/core-templates/jobs/jobs.yml b/eng/common/core-templates/jobs/jobs.yml index bf33cdc2cc7..01ada747665 100644 --- a/eng/common/core-templates/jobs/jobs.yml +++ b/eng/common/core-templates/jobs/jobs.yml @@ -5,9 +5,6 @@ parameters: # Optional: Include PublishBuildArtifacts task enablePublishBuildArtifacts: false - # Optional: Enable publishing using release pipelines - enablePublishUsingPipelines: false - # Optional: Enable running the source-build jobs to build repo from source enableSourceBuild: false @@ -30,6 +27,9 @@ parameters: # Optional: Publish the assets as soon as the publish to BAR stage is complete, rather doing so in a separate stage. publishAssetsImmediately: false + # Optional: 🌤️ or not the build has assets it wants to publish to BAR + isAssetlessBuild: false + # Optional: If using publishAssetsImmediately and additional parameters are needed, can be used to send along additional parameters (normally sent to post-build.yml) artifactsPublishingAdditionalParameters: '' signingValidationAdditionalParameters: '' @@ -85,7 +85,6 @@ jobs: - template: /eng/common/core-templates/jobs/source-build.yml parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} - allCompletedJobId: Source_Build_Complete ${{ each parameter in parameters.sourceBuildParameters }}: ${{ parameter.key }}: ${{ parameter.value }} @@ -98,7 +97,7 @@ jobs: ${{ parameter.key }}: ${{ parameter.value }} - ${{ if and(eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: - - ${{ if or(eq(parameters.enablePublishBuildAssets, true), eq(parameters.artifacts.publish.manifests, 'true'), ne(parameters.artifacts.publish.manifests, '')) }}: + - ${{ if or(eq(parameters.enablePublishBuildAssets, true), eq(parameters.artifacts.publish.manifests, 'true'), ne(parameters.artifacts.publish.manifests, ''), eq(parameters.isAssetlessBuild, true)) }}: - template: ../job/publish-build-assets.yml parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} @@ -110,12 +109,10 @@ jobs: - ${{ if eq(parameters.publishBuildAssetsDependsOn, '') }}: - ${{ each job in parameters.jobs }}: - ${{ job.job }} - - ${{ if eq(parameters.enableSourceBuild, true) }}: - - Source_Build_Complete runAsPublic: ${{ parameters.runAsPublic }} - publishUsingPipelines: ${{ parameters.enablePublishUsingPipelines }} - publishAssetsImmediately: ${{ parameters.publishAssetsImmediately }} + publishAssetsImmediately: ${{ or(parameters.publishAssetsImmediately, parameters.isAssetlessBuild) }} + isAssetlessBuild: ${{ parameters.isAssetlessBuild }} enablePublishBuildArtifacts: ${{ parameters.enablePublishBuildArtifacts }} artifactsPublishingAdditionalParameters: ${{ parameters.artifactsPublishingAdditionalParameters }} signingValidationAdditionalParameters: ${{ parameters.signingValidationAdditionalParameters }} diff --git a/eng/common/core-templates/jobs/source-build.yml b/eng/common/core-templates/jobs/source-build.yml index 0b408a67bd5..d92860cba20 100644 --- a/eng/common/core-templates/jobs/source-build.yml +++ b/eng/common/core-templates/jobs/source-build.yml @@ -2,28 +2,19 @@ parameters: # This template adds arcade-powered source-build to CI. A job is created for each platform, as # well as an optional server job that completes when all platform jobs complete. - # The name of the "join" job for all source-build platforms. If set to empty string, the job is - # not included. Existing repo pipelines can use this job depend on all source-build jobs - # completing without maintaining a separate list of every single job ID: just depend on this one - # server job. By default, not included. Recommended name if used: 'Source_Build_Complete'. - allCompletedJobId: '' - # See /eng/common/core-templates/job/source-build.yml jobNamePrefix: 'Source_Build' # This is the default platform provided by Arcade, intended for use by a managed-only repo. defaultManagedPlatform: name: 'Managed' - container: 'mcr.microsoft.com/dotnet-buildtools/prereqs:centos-stream9' + container: 'mcr.microsoft.com/dotnet-buildtools/prereqs:centos-stream-10-amd64' # Defines the platforms on which to run build jobs. One job is created for each platform, and the # object in this array is sent to the job template as 'platform'. If no platforms are specified, # one job runs on 'defaultManagedPlatform'. platforms: [] - # Optional list of directories to ignore for component governance scans. - componentGovernanceIgnoreDirectories: [] - is1ESPipeline: '' # If set to true and running on a non-public project, @@ -34,23 +25,12 @@ parameters: jobs: -- ${{ if ne(parameters.allCompletedJobId, '') }}: - - job: ${{ parameters.allCompletedJobId }} - displayName: Source-Build Complete - pool: server - dependsOn: - - ${{ each platform in parameters.platforms }}: - - ${{ parameters.jobNamePrefix }}_${{ platform.name }} - - ${{ if eq(length(parameters.platforms), 0) }}: - - ${{ parameters.jobNamePrefix }}_${{ parameters.defaultManagedPlatform.name }} - - ${{ each platform in parameters.platforms }}: - template: /eng/common/core-templates/job/source-build.yml parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} jobNamePrefix: ${{ parameters.jobNamePrefix }} platform: ${{ platform }} - componentGovernanceIgnoreDirectories: ${{ parameters.componentGovernanceIgnoreDirectories }} enableInternalSources: ${{ parameters.enableInternalSources }} - ${{ if eq(length(parameters.platforms), 0) }}: @@ -59,5 +39,4 @@ jobs: is1ESPipeline: ${{ parameters.is1ESPipeline }} jobNamePrefix: ${{ parameters.jobNamePrefix }} platform: ${{ parameters.defaultManagedPlatform }} - componentGovernanceIgnoreDirectories: ${{ parameters.componentGovernanceIgnoreDirectories }} enableInternalSources: ${{ parameters.enableInternalSources }} diff --git a/eng/common/core-templates/post-build/post-build.yml b/eng/common/core-templates/post-build/post-build.yml index 864427d9694..b942a79ef02 100644 --- a/eng/common/core-templates/post-build/post-build.yml +++ b/eng/common/core-templates/post-build/post-build.yml @@ -60,6 +60,11 @@ parameters: artifactNames: '' downloadArtifacts: true + - name: isAssetlessBuild + type: boolean + displayName: Is Assetless Build + default: false + # These parameters let the user customize the call to sdk-task.ps1 for publishing # symbols & general artifacts as well as for signing validation - name: symbolPublishingAdditionalParameters @@ -122,11 +127,11 @@ stages: ${{ else }}: ${{ if eq(parameters.is1ESPipeline, true) }}: name: $(DncEngInternalBuildPool) - image: windows.vs2022.amd64 + image: windows.vs2026preview.scout.amd64 os: windows ${{ else }}: name: $(DncEngInternalBuildPool) - demands: ImageOverride -equals windows.vs2022.amd64 + demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml @@ -170,7 +175,7 @@ stages: os: windows ${{ else }}: name: $(DncEngInternalBuildPool) - demands: ImageOverride -equals windows.vs2022.amd64 + demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml parameters: @@ -188,9 +193,6 @@ stages: buildId: $(AzDOBuildId) artifactName: PackageArtifacts checkDownloadedFiles: true - itemPattern: | - ** - !**/Microsoft.SourceBuild.Intermediate.*.nupkg # This is necessary whenever we want to publish/restore to an AzDO private feed # Since sdk-task.ps1 tries to restore packages we need to do this authentication here @@ -234,7 +236,7 @@ stages: os: windows ${{ else }}: name: $(DncEngInternalBuildPool) - demands: ImageOverride -equals windows.vs2022.amd64 + demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml parameters: @@ -305,6 +307,13 @@ stages: - task: NuGetAuthenticate@1 + # Populate internal runtime variables. + - template: /eng/common/templates/steps/enable-internal-sources.yml + parameters: + legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) + + - template: /eng/common/templates/steps/enable-internal-runtimes.yml + # Darc is targeting 8.0, so make sure it's installed - task: UseDotNet@2 inputs: @@ -325,3 +334,6 @@ stages: -RequireDefaultChannels ${{ parameters.requireDefaultChannels }} -ArtifactsPublishingAdditionalParameters '${{ parameters.artifactsPublishingAdditionalParameters }}' -SymbolPublishingAdditionalParameters '${{ parameters.symbolPublishingAdditionalParameters }}' + -SkipAssetsPublishing '${{ parameters.isAssetlessBuild }}' + -runtimeSourceFeed https://ci.dot.net/internal + -runtimeSourceFeedKey '$(dotnetbuilds-internal-container-read-token-base64)' diff --git a/eng/common/core-templates/steps/cleanup-microbuild.yml b/eng/common/core-templates/steps/cleanup-microbuild.yml new file mode 100644 index 00000000000..c0fdcd3379d --- /dev/null +++ b/eng/common/core-templates/steps/cleanup-microbuild.yml @@ -0,0 +1,28 @@ +parameters: + # Enable cleanup tasks for MicroBuild + enableMicrobuild: false + # Enable cleanup tasks for MicroBuild on Mac and Linux + # Will be ignored if 'enableMicrobuild' is false or 'Agent.Os' is 'Windows_NT' + enableMicrobuildForMacAndLinux: false + continueOnError: false + +steps: + - ${{ if eq(parameters.enableMicrobuild, 'true') }}: + - task: MicroBuildCleanup@1 + displayName: Execute Microbuild cleanup tasks + condition: and( + always(), + or( + and( + eq(variables['Agent.Os'], 'Windows_NT'), + in(variables['_SignType'], 'real', 'test') + ), + and( + ${{ eq(parameters.enableMicrobuildForMacAndLinux, true) }}, + ne(variables['Agent.Os'], 'Windows_NT'), + eq(variables['_SignType'], 'real') + ) + )) + continueOnError: ${{ parameters.continueOnError }} + env: + TeamName: $(_TeamName) diff --git a/eng/common/core-templates/steps/generate-sbom.yml b/eng/common/core-templates/steps/generate-sbom.yml index 7f5b84c4cb8..c05f6502797 100644 --- a/eng/common/core-templates/steps/generate-sbom.yml +++ b/eng/common/core-templates/steps/generate-sbom.yml @@ -5,7 +5,7 @@ # IgnoreDirectories - Directories to ignore for SBOM generation. This will be passed through to the CG component detector. parameters: - PackageVersion: 9.0.0 + PackageVersion: 10.0.0 BuildDropPath: '$(System.DefaultWorkingDirectory)/artifacts' PackageName: '.NET' ManifestDirPath: $(Build.ArtifactStagingDirectory)/sbom diff --git a/eng/common/core-templates/steps/get-delegation-sas.yml b/eng/common/core-templates/steps/get-delegation-sas.yml index 9db5617ea7d..d2901470a7f 100644 --- a/eng/common/core-templates/steps/get-delegation-sas.yml +++ b/eng/common/core-templates/steps/get-delegation-sas.yml @@ -31,16 +31,7 @@ steps: # Calculate the expiration of the SAS token and convert to UTC $expiry = (Get-Date).AddHours(${{ parameters.expiryInHours }}).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - # Temporarily work around a helix issue where SAS tokens with / in them will cause incorrect downloads - # of correlation payloads. https://github.com/dotnet/dnceng/issues/3484 - $sas = "" - do { - $sas = az storage container generate-sas --account-name ${{ parameters.storageAccount }} --name ${{ parameters.container }} --permissions ${{ parameters.permissions }} --expiry $expiry --auth-mode login --as-user -o tsv - if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to generate SAS token." - exit 1 - } - } while($sas.IndexOf('/') -ne -1) + $sas = az storage container generate-sas --account-name ${{ parameters.storageAccount }} --name ${{ parameters.container }} --permissions ${{ parameters.permissions }} --expiry $expiry --auth-mode login --as-user -o tsv if ($LASTEXITCODE -ne 0) { Write-Error "Failed to generate SAS token." diff --git a/eng/common/core-templates/steps/install-microbuild.yml b/eng/common/core-templates/steps/install-microbuild.yml new file mode 100644 index 00000000000..553fce66b94 --- /dev/null +++ b/eng/common/core-templates/steps/install-microbuild.yml @@ -0,0 +1,110 @@ +parameters: + # Enable install tasks for MicroBuild + enableMicrobuild: false + # Enable install tasks for MicroBuild on Mac and Linux + # Will be ignored if 'enableMicrobuild' is false or 'Agent.Os' is 'Windows_NT' + enableMicrobuildForMacAndLinux: false + # Determines whether the ESRP service connection information should be passed to the signing plugin. + # This overlaps with _SignType to some degree. We only need the service connection for real signing. + # It's important that the service connection not be passed to the MicroBuildSigningPlugin task in this place. + # Doing so will cause the service connection to be authorized for the pipeline, which isn't allowed and won't work for non-prod. + # Unfortunately, _SignType can't be used to exclude the use of the service connection in non-real sign scenarios. The + # variable is not available in template expression. _SignType has a very large proliferation across .NET, so replacing it is tough. + microbuildUseESRP: true + # Microbuild installation directory + microBuildOutputFolder: $(Agent.TempDirectory)/MicroBuild + + continueOnError: false + +steps: + - ${{ if eq(parameters.enableMicrobuild, 'true') }}: + - ${{ if eq(parameters.enableMicrobuildForMacAndLinux, 'true') }}: + # Needed to download the MicroBuild plugin nupkgs on Mac and Linux when nuget.exe is unavailable + - task: UseDotNet@2 + displayName: Install .NET 8.0 SDK for MicroBuild Plugin + inputs: + packageType: sdk + version: 8.0.x + installationPath: ${{ parameters.microBuildOutputFolder }}/.dotnet-microbuild + condition: and(succeeded(), ne(variables['Agent.Os'], 'Windows_NT')) + + - script: | + set -euo pipefail + + # UseDotNet@2 prepends the dotnet executable path to the PATH variable, so we can call dotnet directly + version=$(dotnet --version) + cat << 'EOF' > ${{ parameters.microBuildOutputFolder }}/global.json + { + "sdk": { + "version": "$version", + "paths": [ + "${{ parameters.microBuildOutputFolder }}/.dotnet-microbuild" + ], + "errorMessage": "The .NET SDK version $version is required to install the MicroBuild signing plugin." + } + } + EOF + displayName: 'Add global.json to MicroBuild Installation path' + workingDirectory: ${{ parameters.microBuildOutputFolder }} + condition: and(succeeded(), ne(variables['Agent.Os'], 'Windows_NT')) + + - script: | + REM Check if ESRP is disabled while SignType is real + if /I "${{ parameters.microbuildUseESRP }}"=="false" if /I "$(_SignType)"=="real" ( + echo Error: ESRP must be enabled when SignType is real. + exit /b 1 + ) + displayName: 'Validate ESRP usage (Windows)' + condition: and(succeeded(), eq(variables['Agent.Os'], 'Windows_NT')) + - script: | + # Check if ESRP is disabled while SignType is real + if [ "${{ parameters.microbuildUseESRP }}" = "false" ] && [ "$(_SignType)" = "real" ]; then + echo "Error: ESRP must be enabled when SignType is real." + exit 1 + fi + displayName: 'Validate ESRP usage (Non-Windows)' + condition: and(succeeded(), ne(variables['Agent.Os'], 'Windows_NT')) + + # Two different MB install steps. This is due to not being able to use the agent OS during + # YAML expansion, and Windows vs. Linux/Mac uses different service connections. However, + # we can avoid including the MB install step if not enabled at all. This avoids a bunch of + # extra pipeline authorizations, since most pipelines do not sign on non-Windows. + - task: MicroBuildSigningPlugin@4 + displayName: Install MicroBuild plugin (Windows) + inputs: + signType: $(_SignType) + zipSources: false + feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json + ${{ if eq(parameters.microbuildUseESRP, true) }}: + ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' + ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: + ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea + ${{ else }}: + ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca + env: + TeamName: $(_TeamName) + MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + continueOnError: ${{ parameters.continueOnError }} + condition: and(succeeded(), eq(variables['Agent.Os'], 'Windows_NT'), in(variables['_SignType'], 'real', 'test')) + + - ${{ if eq(parameters.enableMicrobuildForMacAndLinux, true) }}: + - task: MicroBuildSigningPlugin@4 + displayName: Install MicroBuild plugin (non-Windows) + inputs: + signType: $(_SignType) + zipSources: false + feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json + workingDirectory: ${{ parameters.microBuildOutputFolder }} + ${{ if eq(parameters.microbuildUseESRP, true) }}: + ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' + ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: + ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 + ${{ else }}: + ConnectedPMEServiceName: c24de2a5-cc7a-493d-95e4-8e5ff5cad2bc + env: + TeamName: $(_TeamName) + MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + continueOnError: ${{ parameters.continueOnError }} + condition: and(succeeded(), ne(variables['Agent.Os'], 'Windows_NT'), eq(variables['_SignType'], 'real')) diff --git a/eng/common/core-templates/steps/publish-logs.yml b/eng/common/core-templates/steps/publish-logs.yml index 0623ac6e112..a9ea99ba6aa 100644 --- a/eng/common/core-templates/steps/publish-logs.yml +++ b/eng/common/core-templates/steps/publish-logs.yml @@ -26,15 +26,18 @@ steps: # If the file exists - sensitive data for redaction will be sourced from it # (single entry per line, lines starting with '# ' are considered comments and skipped) arguments: -InputPath '$(System.DefaultWorkingDirectory)/PostBuildLogs' - -BinlogToolVersion ${{parameters.BinlogToolVersion}} + -BinlogToolVersion '${{parameters.BinlogToolVersion}}' -TokensFilePath '$(System.DefaultWorkingDirectory)/eng/BinlogSecretsRedactionFile.txt' + -runtimeSourceFeed https://ci.dot.net/internal + -runtimeSourceFeedKey '$(dotnetbuilds-internal-container-read-token-base64)' '$(publishing-dnceng-devdiv-code-r-build-re)' - '$(MaestroAccessToken)' '$(dn-bot-all-orgs-artifact-feeds-rw)' '$(akams-client-id)' '$(microsoft-symbol-server-pat)' '$(symweb-symbol-server-pat)' + '$(dnceng-symbol-server-pat)' '$(dn-bot-all-orgs-build-rw-code-rw)' + '$(System.AccessToken)' ${{parameters.CustomSensitiveDataList}} continueOnError: true condition: always() @@ -45,6 +48,7 @@ steps: SourceFolder: '$(System.DefaultWorkingDirectory)/PostBuildLogs' Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)/PostBuildLogs' + condition: always() - template: /eng/common/core-templates/steps/publish-build-artifacts.yml parameters: diff --git a/eng/common/core-templates/steps/source-build.yml b/eng/common/core-templates/steps/source-build.yml index 7846584d2a7..b9c86c18ae4 100644 --- a/eng/common/core-templates/steps/source-build.yml +++ b/eng/common/core-templates/steps/source-build.yml @@ -11,10 +11,6 @@ parameters: # for details. The entire object is described in the 'job' template for simplicity, even though # the usage of the properties on this object is split between the 'job' and 'steps' templates. platform: {} - - # Optional list of directories to ignore for component governance scans. - componentGovernanceIgnoreDirectories: [] - is1ESPipeline: false steps: @@ -23,25 +19,12 @@ steps: set -x df -h - # If file changes are detected, set CopyWipIntoInnerSourceBuildRepo to copy the WIP changes into the inner source build repo. - internalRestoreArgs= - if ! git diff --quiet; then - internalRestoreArgs='/p:CopyWipIntoInnerSourceBuildRepo=true' - # The 'Copy WIP' feature of source build uses git stash to apply changes from the original repo. - # This only works if there is a username/email configured, which won't be the case in most CI runs. - git config --get user.email - if [ $? -ne 0 ]; then - git config user.email dn-bot@microsoft.com - git config user.name dn-bot - fi - fi - # If building on the internal project, the internal storage variable may be available (usually only if needed) # In that case, add variables to allow the download of internal runtimes if the specified versions are not found # in the default public locations. internalRuntimeDownloadArgs= if [ '$(dotnetbuilds-internal-container-read-token-base64)' != '$''(dotnetbuilds-internal-container-read-token-base64)' ]; then - internalRuntimeDownloadArgs='/p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) --runtimesourcefeed https://ci.dot.net/internal --runtimesourcefeedkey $(dotnetbuilds-internal-container-read-token-base64)' + internalRuntimeDownloadArgs='/p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) --runtimesourcefeed https://ci.dot.net/internal --runtimesourcefeedkey '$(dotnetbuilds-internal-container-read-token-base64)'' fi buildConfig=Release @@ -50,88 +33,33 @@ steps: buildConfig='$(_BuildConfig)' fi - officialBuildArgs= - if [ '${{ and(ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}' = 'True' ]; then - officialBuildArgs='/p:DotNetPublishUsingPipelines=true /p:OfficialBuildId=$(BUILD.BUILDNUMBER)' - fi - targetRidArgs= if [ '${{ parameters.platform.targetRID }}' != '' ]; then targetRidArgs='/p:TargetRid=${{ parameters.platform.targetRID }}' fi - runtimeOsArgs= - if [ '${{ parameters.platform.runtimeOS }}' != '' ]; then - runtimeOsArgs='/p:RuntimeOS=${{ parameters.platform.runtimeOS }}' - fi - - baseOsArgs= - if [ '${{ parameters.platform.baseOS }}' != '' ]; then - baseOsArgs='/p:BaseOS=${{ parameters.platform.baseOS }}' - fi - - publishArgs= - if [ '${{ parameters.platform.skipPublishValidation }}' != 'true' ]; then - publishArgs='--publish' - fi - - assetManifestFileName=SourceBuild_RidSpecific.xml - if [ '${{ parameters.platform.name }}' != '' ]; then - assetManifestFileName=SourceBuild_${{ parameters.platform.name }}.xml + portableBuildArgs= + if [ '${{ parameters.platform.portableBuild }}' != '' ]; then + portableBuildArgs='/p:PortableBuild=${{ parameters.platform.portableBuild }}' fi ${{ coalesce(parameters.platform.buildScript, './build.sh') }} --ci \ --configuration $buildConfig \ - --restore --build --pack $publishArgs -bl \ + --restore --build --pack -bl \ + --source-build \ ${{ parameters.platform.buildArguments }} \ - $officialBuildArgs \ $internalRuntimeDownloadArgs \ - $internalRestoreArgs \ $targetRidArgs \ - $runtimeOsArgs \ - $baseOsArgs \ - /p:SourceBuildNonPortable=${{ parameters.platform.nonPortable }} \ - /p:ArcadeBuildFromSource=true \ - /p:DotNetBuildSourceOnly=true \ - /p:DotNetBuildRepo=true \ - /p:AssetManifestFileName=$assetManifestFileName + $portableBuildArgs \ displayName: Build -# Upload build logs for diagnosis. -- task: CopyFiles@2 - displayName: Prepare BuildLogs staging directory - inputs: - SourceFolder: '$(System.DefaultWorkingDirectory)' - Contents: | - **/*.log - **/*.binlog - artifacts/sb/prebuilt-report/** - TargetFolder: '$(Build.StagingDirectory)/BuildLogs' - CleanTargetFolder: true - continueOnError: true - condition: succeededOrFailed() - - template: /eng/common/core-templates/steps/publish-pipeline-artifacts.yml parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} args: displayName: Publish BuildLogs - targetPath: '$(Build.StagingDirectory)/BuildLogs' + targetPath: artifacts/log/${{ coalesce(variables._BuildConfig, 'Release') }} artifactName: BuildLogs_SourceBuild_${{ parameters.platform.name }}_Attempt$(System.JobAttempt) continueOnError: true condition: succeededOrFailed() sbomEnabled: false # we don't need SBOM for logs - -# Manually inject component detection so that we can ignore the source build upstream cache, which contains -# a nupkg cache of input packages (a local feed). -# This path must match the upstream cache path in property 'CurrentRepoSourceBuiltNupkgCacheDir' -# in src\Microsoft.DotNet.Arcade.Sdk\tools\SourceBuild\SourceBuildArcade.targets -- template: /eng/common/core-templates/steps/component-governance.yml - parameters: - displayName: Component Detection (Exclude upstream cache) - is1ESPipeline: ${{ parameters.is1ESPipeline }} - ${{ if eq(length(parameters.componentGovernanceIgnoreDirectories), 0) }}: - componentGovernanceIgnoreDirectories: '$(System.DefaultWorkingDirectory)/artifacts/sb/src/artifacts/obj/source-built-upstream-cache' - ${{ else }}: - componentGovernanceIgnoreDirectories: ${{ join(',', parameters.componentGovernanceIgnoreDirectories) }} - disableComponentGovernance: ${{ eq(variables['System.TeamProject'], 'public') }} diff --git a/eng/common/core-templates/steps/source-index-stage1-publish.yml b/eng/common/core-templates/steps/source-index-stage1-publish.yml new file mode 100644 index 00000000000..e9a694afa58 --- /dev/null +++ b/eng/common/core-templates/steps/source-index-stage1-publish.yml @@ -0,0 +1,35 @@ +parameters: + sourceIndexUploadPackageVersion: 2.0.0-20250818.1 + sourceIndexProcessBinlogPackageVersion: 1.0.1-20250818.1 + sourceIndexPackageSource: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json + binlogPath: artifacts/log/Debug/Build.binlog + +steps: +- task: UseDotNet@2 + displayName: "Source Index: Use .NET 9 SDK" + inputs: + packageType: sdk + version: 9.0.x + installationPath: $(Agent.TempDirectory)/dotnet + workingDirectory: $(Agent.TempDirectory) + +- script: | + $(Agent.TempDirectory)/dotnet/dotnet tool install BinLogToSln --version ${{parameters.sourceIndexProcessBinlogPackageVersion}} --add-source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools + $(Agent.TempDirectory)/dotnet/dotnet tool install UploadIndexStage1 --version ${{parameters.sourceIndexUploadPackageVersion}} --add-source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools + displayName: "Source Index: Download netsourceindex Tools" + # Set working directory to temp directory so 'dotnet' doesn't try to use global.json and use the repo's sdk. + workingDirectory: $(Agent.TempDirectory) + +- script: $(Agent.TempDirectory)/.source-index/tools/BinLogToSln -i ${{parameters.BinlogPath}} -r $(System.DefaultWorkingDirectory) -n $(Build.Repository.Name) -o .source-index/stage1output + displayName: "Source Index: Process Binlog into indexable sln" + +- ${{ if and(ne(parameters.runAsPublic, 'true'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: + - task: AzureCLI@2 + displayName: "Source Index: Upload Source Index stage1 artifacts to Azure" + inputs: + azureSubscription: 'SourceDotNet Stage1 Publish' + addSpnToEnvironment: true + scriptType: 'ps' + scriptLocation: 'inlineScript' + inlineScript: | + $(Agent.TempDirectory)/.source-index/tools/UploadIndexStage1 -i .source-index/stage1output -n $(Build.Repository.Name) -s netsourceindexstage1 -b stage1 diff --git a/eng/common/cross/arm64/tizen/tizen.patch b/eng/common/cross/arm64/tizen/tizen.patch index af7c8be0590..2cebc547382 100644 --- a/eng/common/cross/arm64/tizen/tizen.patch +++ b/eng/common/cross/arm64/tizen/tizen.patch @@ -5,5 +5,5 @@ diff -u -r a/usr/lib/libc.so b/usr/lib/libc.so Use the shared library, but some functions are only in the static library, so try that secondarily. */ OUTPUT_FORMAT(elf64-littleaarch64) --GROUP ( /lib64/libc.so.6 /usr/lib64/libc_nonshared.a AS_NEEDED ( /lib/ld-linux-aarch64.so.1 ) ) +-GROUP ( /lib64/libc.so.6 /usr/lib64/libc_nonshared.a AS_NEEDED ( /lib64/ld-linux-aarch64.so.1 ) ) +GROUP ( libc.so.6 libc_nonshared.a AS_NEEDED ( ld-linux-aarch64.so.1 ) ) diff --git a/eng/common/cross/armel/armel.jessie.patch b/eng/common/cross/armel/armel.jessie.patch deleted file mode 100644 index 2d261561935..00000000000 --- a/eng/common/cross/armel/armel.jessie.patch +++ /dev/null @@ -1,43 +0,0 @@ -diff -u -r a/usr/include/urcu/uatomic/generic.h b/usr/include/urcu/uatomic/generic.h ---- a/usr/include/urcu/uatomic/generic.h 2014-10-22 15:00:58.000000000 -0700 -+++ b/usr/include/urcu/uatomic/generic.h 2020-10-30 21:38:28.550000000 -0700 -@@ -69,10 +69,10 @@ - #endif - #ifdef UATOMIC_HAS_ATOMIC_SHORT - case 2: -- return __sync_val_compare_and_swap_2(addr, old, _new); -+ return __sync_val_compare_and_swap_2((uint16_t*) addr, old, _new); - #endif - case 4: -- return __sync_val_compare_and_swap_4(addr, old, _new); -+ return __sync_val_compare_and_swap_4((uint32_t*) addr, old, _new); - #if (CAA_BITS_PER_LONG == 64) - case 8: - return __sync_val_compare_and_swap_8(addr, old, _new); -@@ -109,7 +109,7 @@ - return; - #endif - case 4: -- __sync_and_and_fetch_4(addr, val); -+ __sync_and_and_fetch_4((uint32_t*) addr, val); - return; - #if (CAA_BITS_PER_LONG == 64) - case 8: -@@ -148,7 +148,7 @@ - return; - #endif - case 4: -- __sync_or_and_fetch_4(addr, val); -+ __sync_or_and_fetch_4((uint32_t*) addr, val); - return; - #if (CAA_BITS_PER_LONG == 64) - case 8: -@@ -187,7 +187,7 @@ - return __sync_add_and_fetch_2(addr, val); - #endif - case 4: -- return __sync_add_and_fetch_4(addr, val); -+ return __sync_add_and_fetch_4((uint32_t*) addr, val); - #if (CAA_BITS_PER_LONG == 64) - case 8: - return __sync_add_and_fetch_8(addr, val); diff --git a/eng/common/cross/build-android-rootfs.sh b/eng/common/cross/build-android-rootfs.sh index 7e9ba2b75ed..fbd8d80848a 100755 --- a/eng/common/cross/build-android-rootfs.sh +++ b/eng/common/cross/build-android-rootfs.sh @@ -6,10 +6,11 @@ usage() { echo "Creates a toolchain and sysroot used for cross-compiling for Android." echo - echo "Usage: $0 [BuildArch] [ApiLevel]" + echo "Usage: $0 [BuildArch] [ApiLevel] [--ndk NDKVersion]" echo echo "BuildArch is the target architecture of Android. Currently only arm64 is supported." echo "ApiLevel is the target Android API level. API levels usually match to Android releases. See https://source.android.com/source/build-numbers.html" + echo "NDKVersion is the version of Android NDK. The default is r21. See https://developer.android.com/ndk/downloads/revision_history" echo echo "By default, the toolchain and sysroot will be generated in cross/android-rootfs/toolchain/[BuildArch]. You can change this behavior" echo "by setting the TOOLCHAIN_DIR environment variable" @@ -25,10 +26,15 @@ __BuildArch=arm64 __AndroidArch=aarch64 __AndroidToolchain=aarch64-linux-android -for i in "$@" - do - lowerI="$(echo $i | tr "[:upper:]" "[:lower:]")" - case $lowerI in +while :; do + if [[ "$#" -le 0 ]]; then + break + fi + + i=$1 + + lowerI="$(echo $i | tr "[:upper:]" "[:lower:]")" + case $lowerI in -?|-h|--help) usage exit 1 @@ -43,6 +49,10 @@ for i in "$@" __AndroidArch=arm __AndroidToolchain=arm-linux-androideabi ;; + --ndk) + shift + __NDK_Version=$1 + ;; *[0-9]) __ApiLevel=$i ;; @@ -50,8 +60,17 @@ for i in "$@" __UnprocessedBuildArgs="$__UnprocessedBuildArgs $i" ;; esac + shift done +if [[ "$__NDK_Version" == "r21" ]] || [[ "$__NDK_Version" == "r22" ]]; then + __NDK_File_Arch_Spec=-x86_64 + __SysRoot=sysroot +else + __NDK_File_Arch_Spec= + __SysRoot=toolchains/llvm/prebuilt/linux-x86_64/sysroot +fi + # Obtain the location of the bash script to figure out where the root of the repo is. __ScriptBaseDir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" @@ -78,6 +97,7 @@ fi echo "Target API level: $__ApiLevel" echo "Target architecture: $__BuildArch" +echo "NDK version: $__NDK_Version" echo "NDK location: $__NDK_Dir" echo "Target Toolchain location: $__ToolchainDir" @@ -85,8 +105,8 @@ echo "Target Toolchain location: $__ToolchainDir" if [ ! -d $__NDK_Dir ]; then echo Downloading the NDK into $__NDK_Dir mkdir -p $__NDK_Dir - wget -q --progress=bar:force:noscroll --show-progress https://dl.google.com/android/repository/android-ndk-$__NDK_Version-linux-x86_64.zip -O $__CrossDir/android-ndk-$__NDK_Version-linux-x86_64.zip - unzip -q $__CrossDir/android-ndk-$__NDK_Version-linux-x86_64.zip -d $__CrossDir + wget -q --progress=bar:force:noscroll --show-progress https://dl.google.com/android/repository/android-ndk-$__NDK_Version-linux$__NDK_File_Arch_Spec.zip -O $__CrossDir/android-ndk-$__NDK_Version-linux.zip + unzip -q $__CrossDir/android-ndk-$__NDK_Version-linux.zip -d $__CrossDir fi if [ ! -d $__lldb_Dir ]; then @@ -116,16 +136,11 @@ for path in $(wget -qO- https://packages.termux.dev/termux-main-21/dists/stable/ fi done -cp -R "$__TmpDir/data/data/com.termux/files/usr/"* "$__ToolchainDir/sysroot/usr/" +cp -R "$__TmpDir/data/data/com.termux/files/usr/"* "$__ToolchainDir/$__SysRoot/usr/" # Generate platform file for build.sh script to assign to __DistroRid echo "Generating platform file..." -echo "RID=android.${__ApiLevel}-${__BuildArch}" > $__ToolchainDir/sysroot/android_platform - -echo "Now to build coreclr, libraries and installers; run:" -echo ROOTFS_DIR=\$\(realpath $__ToolchainDir/sysroot\) ./build.sh --cross --arch $__BuildArch \ - --subsetCategory coreclr -echo ROOTFS_DIR=\$\(realpath $__ToolchainDir/sysroot\) ./build.sh --cross --arch $__BuildArch \ - --subsetCategory libraries -echo ROOTFS_DIR=\$\(realpath $__ToolchainDir/sysroot\) ./build.sh --cross --arch $__BuildArch \ - --subsetCategory installer +echo "RID=android.${__ApiLevel}-${__BuildArch}" > $__ToolchainDir/$__SysRoot/android_platform + +echo "Now to build coreclr, libraries and host; run:" +echo ROOTFS_DIR=$(realpath $__ToolchainDir/$__SysRoot) ./build.sh clr+libs+host --cross --arch $__BuildArch diff --git a/eng/common/cross/build-rootfs.sh b/eng/common/cross/build-rootfs.sh index 4b5e8d7166b..8abfb71f727 100755 --- a/eng/common/cross/build-rootfs.sh +++ b/eng/common/cross/build-rootfs.sh @@ -5,7 +5,7 @@ set -e usage() { echo "Usage: $0 [BuildArch] [CodeName] [lldbx.y] [llvmx[.y]] [--skipunmount] --rootfsdir ]" - echo "BuildArch can be: arm(default), arm64, armel, armv6, ppc64le, riscv64, s390x, x64, x86" + echo "BuildArch can be: arm(default), arm64, armel, armv6, loongarch64, ppc64le, riscv64, s390x, x64, x86" echo "CodeName - optional, Code name for Linux, can be: xenial(default), zesty, bionic, alpine" echo " for alpine can be specified with version: alpineX.YY or alpineedge" echo " for FreeBSD can be: freebsd13, freebsd14" @@ -15,6 +15,7 @@ usage() echo "llvmx[.y] - optional, LLVM version for LLVM related packages." echo "--skipunmount - optional, will skip the unmount of rootfs folder." echo "--skipsigcheck - optional, will skip package signature checks (allowing untrusted packages)." + echo "--skipemulation - optional, will skip qemu and debootstrap requirement when building environment for debian based systems." echo "--use-mirror - optional, use mirror URL to fetch resources, when available." echo "--jobs N - optional, restrict to N jobs." exit 1 @@ -52,28 +53,27 @@ __UbuntuPackages+=" symlinks" __UbuntuPackages+=" libicu-dev" __UbuntuPackages+=" liblttng-ust-dev" __UbuntuPackages+=" libunwind8-dev" -__UbuntuPackages+=" libnuma-dev" __AlpinePackages+=" gettext-dev" __AlpinePackages+=" icu-dev" __AlpinePackages+=" libunwind-dev" __AlpinePackages+=" lttng-ust-dev" __AlpinePackages+=" compiler-rt" -__AlpinePackages+=" numactl-dev" # runtime libraries' dependencies __UbuntuPackages+=" libcurl4-openssl-dev" __UbuntuPackages+=" libkrb5-dev" __UbuntuPackages+=" libssl-dev" __UbuntuPackages+=" zlib1g-dev" +__UbuntuPackages+=" libbrotli-dev" __AlpinePackages+=" curl-dev" __AlpinePackages+=" krb5-dev" __AlpinePackages+=" openssl-dev" __AlpinePackages+=" zlib-dev" -__FreeBSDBase="13.3-RELEASE" -__FreeBSDPkg="1.17.0" +__FreeBSDBase="13.4-RELEASE" +__FreeBSDPkg="1.21.3" __FreeBSDABI="13" __FreeBSDPackages="libunwind" __FreeBSDPackages+=" icu" @@ -91,18 +91,18 @@ __HaikuPackages="gcc_syslibs" __HaikuPackages+=" gcc_syslibs_devel" __HaikuPackages+=" gmp" __HaikuPackages+=" gmp_devel" -__HaikuPackages+=" icu66" -__HaikuPackages+=" icu66_devel" +__HaikuPackages+=" icu[0-9]+" +__HaikuPackages+=" icu[0-9]*_devel" __HaikuPackages+=" krb5" __HaikuPackages+=" krb5_devel" __HaikuPackages+=" libiconv" __HaikuPackages+=" libiconv_devel" -__HaikuPackages+=" llvm12_libunwind" -__HaikuPackages+=" llvm12_libunwind_devel" +__HaikuPackages+=" llvm[0-9]*_libunwind" +__HaikuPackages+=" llvm[0-9]*_libunwind_devel" __HaikuPackages+=" mpfr" __HaikuPackages+=" mpfr_devel" -__HaikuPackages+=" openssl" -__HaikuPackages+=" openssl_devel" +__HaikuPackages+=" openssl3" +__HaikuPackages+=" openssl3_devel" __HaikuPackages+=" zlib" __HaikuPackages+=" zlib_devel" @@ -128,10 +128,12 @@ __AlpineKeys=' 616adfeb:MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAq0BFD1D4lIxQcsqEpQzU\npNCYM3aP1V/fxxVdT4DWvSI53JHTwHQamKdMWtEXetWVbP5zSROniYKFXd/xrD9X\n0jiGHey3lEtylXRIPxe5s+wXoCmNLcJVnvTcDtwx/ne2NLHxp76lyc25At+6RgE6\nADjLVuoD7M4IFDkAsd8UQ8zM0Dww9SylIk/wgV3ZkifecvgUQRagrNUdUjR56EBZ\nraQrev4hhzOgwelT0kXCu3snbUuNY/lU53CoTzfBJ5UfEJ5pMw1ij6X0r5S9IVsy\nKLWH1hiO0NzU2c8ViUYCly4Fe9xMTFc6u2dy/dxf6FwERfGzETQxqZvSfrRX+GLj\n/QZAXiPg5178hT/m0Y3z5IGenIC/80Z9NCi+byF1WuJlzKjDcF/TU72zk0+PNM/H\nKuppf3JT4DyjiVzNC5YoWJT2QRMS9KLP5iKCSThwVceEEg5HfhQBRT9M6KIcFLSs\nmFjx9kNEEmc1E8hl5IR3+3Ry8G5/bTIIruz14jgeY9u5jhL8Vyyvo41jgt9sLHR1\n/J1TxKfkgksYev7PoX6/ZzJ1ksWKZY5NFoDXTNYUgzFUTOoEaOg3BAQKadb3Qbbq\nXIrxmPBdgrn9QI7NCgfnAY3Tb4EEjs3ON/BNyEhUENcXOH6I1NbcuBQ7g9P73kE4\nVORdoc8MdJ5eoKBpO8Ww8HECAwEAAQ== 616ae350:MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyduVzi1mWm+lYo2Tqt/0\nXkCIWrDNP1QBMVPrE0/ZlU2bCGSoo2Z9FHQKz/mTyMRlhNqTfhJ5qU3U9XlyGOPJ\npiM+b91g26pnpXJ2Q2kOypSgOMOPA4cQ42PkHBEqhuzssfj9t7x47ppS94bboh46\nxLSDRff/NAbtwTpvhStV3URYkxFG++cKGGa5MPXBrxIp+iZf9GnuxVdST5PGiVGP\nODL/b69sPJQNbJHVquqUTOh5Ry8uuD2WZuXfKf7/C0jC/ie9m2+0CttNu9tMciGM\nEyKG1/Xhk5iIWO43m4SrrT2WkFlcZ1z2JSf9Pjm4C2+HovYpihwwdM/OdP8Xmsnr\nDzVB4YvQiW+IHBjStHVuyiZWc+JsgEPJzisNY0Wyc/kNyNtqVKpX6dRhMLanLmy+\nf53cCSI05KPQAcGj6tdL+D60uKDkt+FsDa0BTAobZ31OsFVid0vCXtsbplNhW1IF\nHwsGXBTVcfXg44RLyL8Lk/2dQxDHNHzAUslJXzPxaHBLmt++2COa2EI1iWlvtznk\nOk9WP8SOAIj+xdqoiHcC4j72BOVVgiITIJNHrbppZCq6qPR+fgXmXa+sDcGh30m6\n9Wpbr28kLMSHiENCWTdsFij+NQTd5S47H7XTROHnalYDuF1RpS+DpQidT5tUimaT\nJZDr++FjKrnnijbyNF8b98UCAwEAAQ== 616db30d:MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAnpUpyWDWjlUk3smlWeA0\nlIMW+oJ38t92CRLHH3IqRhyECBRW0d0aRGtq7TY8PmxjjvBZrxTNDpJT6KUk4LRm\na6A6IuAI7QnNK8SJqM0DLzlpygd7GJf8ZL9SoHSH+gFsYF67Cpooz/YDqWrlN7Vw\ntO00s0B+eXy+PCXYU7VSfuWFGK8TGEv6HfGMALLjhqMManyvfp8hz3ubN1rK3c8C\nUS/ilRh1qckdbtPvoDPhSbTDmfU1g/EfRSIEXBrIMLg9ka/XB9PvWRrekrppnQzP\nhP9YE3x/wbFc5QqQWiRCYyQl/rgIMOXvIxhkfe8H5n1Et4VAorkpEAXdsfN8KSVv\nLSMazVlLp9GYq5SUpqYX3KnxdWBgN7BJoZ4sltsTpHQ/34SXWfu3UmyUveWj7wp0\nx9hwsPirVI00EEea9AbP7NM2rAyu6ukcm4m6ATd2DZJIViq2es6m60AE6SMCmrQF\nwmk4H/kdQgeAELVfGOm2VyJ3z69fQuywz7xu27S6zTKi05Qlnohxol4wVb6OB7qG\nLPRtK9ObgzRo/OPumyXqlzAi/Yvyd1ZQk8labZps3e16bQp8+pVPiumWioMFJDWV\nGZjCmyMSU8V6MB6njbgLHoyg2LCukCAeSjbPGGGYhnKLm1AKSoJh3IpZuqcKCk5C\n8CM1S15HxV78s9dFntEqIokCAwEAAQ== +66ba20fe:MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtfB12w4ZgqsXWZDfUAV/\n6Y4aHUKIu3q4SXrNZ7CXF9nXoAVYrS7NAxJdAodsY3vPCN0g5O8DFXR+390LdOuQ\n+HsGKCc1k5tX5ZXld37EZNTNSbR0k+NKhd9h6X3u6wqPOx7SIKxwAQR8qeeFq4pP\nrt9GAGlxtuYgzIIcKJPwE0dZlcBCg+GnptCUZXp/38BP1eYC+xTXSL6Muq1etYfg\nodXdb7Yl+2h1IHuOwo5rjgY5kpY7GcAs8AjGk3lDD/av60OTYccknH0NCVSmPoXK\nvrxDBOn0LQRNBLcAfnTKgHrzy0Q5h4TNkkyTgxkoQw5ObDk9nnabTxql732yy9BY\ns+hM9+dSFO1HKeVXreYSA2n1ndF18YAvAumzgyqzB7I4pMHXq1kC/8bONMJxwSkS\nYm6CoXKyavp7RqGMyeVpRC7tV+blkrrUml0BwNkxE+XnwDRB3xDV6hqgWe0XrifD\nYTfvd9ScZQP83ip0r4IKlq4GMv/R5shcCRJSkSZ6QSGshH40JYSoiwJf5FHbj9ND\n7do0UAqebWo4yNx63j/wb2ULorW3AClv0BCFSdPsIrCStiGdpgJDBR2P2NZOCob3\nG9uMj+wJD6JJg2nWqNJxkANXX37Qf8plgzssrhrgOvB0fjjS7GYhfkfmZTJ0wPOw\nA8+KzFseBh4UFGgue78KwgkCAwEAAQ== ' __Keyring= __KeyringFile="/usr/share/keyrings/ubuntu-archive-keyring.gpg" __SkipSigCheck=0 +__SkipEmulation=0 __UseMirror=0 __UnprocessedBuildArgs= @@ -162,9 +164,13 @@ while :; do armel) __BuildArch=armel __UbuntuArch=armel - __UbuntuRepo="http://ftp.debian.org/debian/" - __CodeName=jessie + __UbuntuRepo="http://archive.debian.org/debian/" + __CodeName=buster __KeyringFile="/usr/share/keyrings/debian-archive-keyring.gpg" + __LLDB_Package="liblldb-6.0-dev" + __UbuntuPackages="${__UbuntuPackages// libomp-dev/}" + __UbuntuPackages="${__UbuntuPackages// libomp5/}" + __UbuntuSuites= ;; armv6) __BuildArch=armv6 @@ -180,6 +186,18 @@ while :; do __Keyring="--keyring $__KeyringFile" fi ;; + loongarch64) + __BuildArch=loongarch64 + __AlpineArch=loongarch64 + __QEMUArch=loongarch64 + __UbuntuArch=loong64 + __UbuntuSuites=unreleased + __LLDB_Package="liblldb-19-dev" + + if [[ "$__CodeName" == "sid" ]]; then + __UbuntuRepo="http://ftp.ports.debian.org/debian-ports/" + fi + ;; riscv64) __BuildArch=riscv64 __AlpineArch=riscv64 @@ -264,44 +282,21 @@ while :; do ;; xenial) # Ubuntu 16.04 - if [[ "$__CodeName" != "jessie" ]]; then - __CodeName=xenial - fi - ;; - zesty) # Ubuntu 17.04 - if [[ "$__CodeName" != "jessie" ]]; then - __CodeName=zesty - fi + __CodeName=xenial ;; bionic) # Ubuntu 18.04 - if [[ "$__CodeName" != "jessie" ]]; then - __CodeName=bionic - fi + __CodeName=bionic ;; focal) # Ubuntu 20.04 - if [[ "$__CodeName" != "jessie" ]]; then - __CodeName=focal - fi + __CodeName=focal ;; jammy) # Ubuntu 22.04 - if [[ "$__CodeName" != "jessie" ]]; then - __CodeName=jammy - fi + __CodeName=jammy ;; noble) # Ubuntu 24.04 - if [[ "$__CodeName" != "jessie" ]]; then - __CodeName=noble - fi - if [[ -n "$__LLDB_Package" ]]; then - __LLDB_Package="liblldb-18-dev" - fi - ;; - jessie) # Debian 8 - __CodeName=jessie - __KeyringFile="/usr/share/keyrings/debian-archive-keyring.gpg" - - if [[ -z "$__UbuntuRepo" ]]; then - __UbuntuRepo="http://ftp.debian.org/debian/" + __CodeName=noble + if [[ -z "$__LLDB_Package" ]]; then + __LLDB_Package="liblldb-19-dev" fi ;; stretch) # Debian 9 @@ -319,7 +314,7 @@ while :; do __KeyringFile="/usr/share/keyrings/debian-archive-keyring.gpg" if [[ -z "$__UbuntuRepo" ]]; then - __UbuntuRepo="http://ftp.debian.org/debian/" + __UbuntuRepo="http://archive.debian.org/debian/" fi ;; bullseye) # Debian 11 @@ -340,10 +335,28 @@ while :; do ;; sid) # Debian sid __CodeName=sid - __KeyringFile="/usr/share/keyrings/debian-archive-keyring.gpg" + __UbuntuSuites= - if [[ -z "$__UbuntuRepo" ]]; then - __UbuntuRepo="http://ftp.debian.org/debian/" + # Debian-Ports architectures need different values + case "$__UbuntuArch" in + amd64|arm64|armel|armhf|i386|mips64el|ppc64el|riscv64|s390x) + __KeyringFile="/usr/share/keyrings/debian-archive-keyring.gpg" + + if [[ -z "$__UbuntuRepo" ]]; then + __UbuntuRepo="http://ftp.debian.org/debian/" + fi + ;; + *) + __KeyringFile="/usr/share/keyrings/debian-ports-archive-keyring.gpg" + + if [[ -z "$__UbuntuRepo" ]]; then + __UbuntuRepo="http://ftp.ports.debian.org/debian-ports/" + fi + ;; + esac + + if [[ -e "$__KeyringFile" ]]; then + __Keyring="--keyring $__KeyringFile" fi ;; tizen) @@ -370,7 +383,7 @@ while :; do ;; freebsd14) __CodeName=freebsd - __FreeBSDBase="14.0-RELEASE" + __FreeBSDBase="14.2-RELEASE" __FreeBSDABI="14" __SkipUnmount=1 ;; @@ -388,6 +401,9 @@ while :; do --skipsigcheck) __SkipSigCheck=1 ;; + --skipemulation) + __SkipEmulation=1 + ;; --rootfsdir|-rootfsdir) shift __RootfsDir="$1" @@ -420,16 +436,15 @@ case "$__AlpineVersion" in elif [[ "$__AlpineArch" == "x86" ]]; then __AlpineVersion=3.17 # minimum version that supports lldb-dev __AlpinePackages+=" llvm15-libs" - elif [[ "$__AlpineArch" == "riscv64" ]]; then + elif [[ "$__AlpineArch" == "riscv64" || "$__AlpineArch" == "loongarch64" ]]; then + __AlpineVersion=3.21 # minimum version that supports lldb-dev + __AlpinePackages+=" llvm19-libs" + elif [[ -n "$__AlpineMajorVersion" ]]; then + # use whichever alpine version is provided and select the latest toolchain libs __AlpineLlvmLibsLookup=1 - __AlpineVersion=edge # minimum version with APKINDEX.tar.gz (packages archive) else __AlpineVersion=3.13 # 3.13 to maximize compatibility __AlpinePackages+=" llvm10-libs" - - if [[ "$__AlpineArch" == "armv7" ]]; then - __AlpinePackages="${__AlpinePackages//numactl-dev/}" - fi fi esac @@ -439,15 +454,6 @@ if [[ "$__AlpineVersion" =~ 3\.1[345] ]]; then __AlpinePackages="${__AlpinePackages/compiler-rt/compiler-rt-static}" fi -if [[ "$__BuildArch" == "armel" ]]; then - __LLDB_Package="lldb-3.5-dev" -fi - -if [[ "$__CodeName" == "xenial" && "$__UbuntuArch" == "armhf" ]]; then - # libnuma-dev is not available on armhf for xenial - __UbuntuPackages="${__UbuntuPackages//libnuma-dev/}" -fi - __UbuntuPackages+=" ${__LLDB_Package:-}" if [[ -z "$__UbuntuRepo" ]]; then @@ -496,7 +502,7 @@ if [[ "$__CodeName" == "alpine" ]]; then arch="$(uname -m)" ensureDownloadTool - + if [[ "$__hasWget" == 1 ]]; then wget -P "$__ApkToolsDir" "https://gitlab.alpinelinux.org/api/v4/projects/5/packages/generic/v$__ApkToolsVersion/$arch/apk.static" else @@ -512,11 +518,6 @@ if [[ "$__CodeName" == "alpine" ]]; then echo "$__ApkToolsSHA512SUM $__ApkToolsDir/apk.static" | sha512sum -c chmod +x "$__ApkToolsDir/apk.static" - if [[ -f "/usr/bin/qemu-$__QEMUArch-static" ]]; then - mkdir -p "$__RootfsDir"/usr/bin - cp -v "/usr/bin/qemu-$__QEMUArch-static" "$__RootfsDir/usr/bin" - fi - if [[ "$__AlpineVersion" == "edge" ]]; then version=edge else @@ -536,6 +537,10 @@ if [[ "$__CodeName" == "alpine" ]]; then __ApkSignatureArg="--keys-dir $__ApkKeysDir" fi + if [[ "$__SkipEmulation" == "1" ]]; then + __NoEmulationArg="--no-scripts" + fi + # initialize DB # shellcheck disable=SC2086 "$__ApkToolsDir/apk.static" \ @@ -557,7 +562,7 @@ if [[ "$__CodeName" == "alpine" ]]; then "$__ApkToolsDir/apk.static" \ -X "http://dl-cdn.alpinelinux.org/alpine/$version/main" \ -X "http://dl-cdn.alpinelinux.org/alpine/$version/community" \ - -U $__ApkSignatureArg --root "$__RootfsDir" --arch "$__AlpineArch" \ + -U $__ApkSignatureArg --root "$__RootfsDir" --arch "$__AlpineArch" $__NoEmulationArg \ add $__AlpinePackages rm -r "$__ApkToolsDir" @@ -573,7 +578,7 @@ elif [[ "$__CodeName" == "freebsd" ]]; then curl -SL "https://download.freebsd.org/ftp/releases/${__FreeBSDArch}/${__FreeBSDMachineArch}/${__FreeBSDBase}/base.txz" | tar -C "$__RootfsDir" -Jxf - ./lib ./usr/lib ./usr/libdata ./usr/include ./usr/share/keys ./etc ./bin/freebsd-version fi echo "ABI = \"FreeBSD:${__FreeBSDABI}:${__FreeBSDMachineArch}\"; FINGERPRINTS = \"${__RootfsDir}/usr/share/keys\"; REPOS_DIR = [\"${__RootfsDir}/etc/pkg\"]; REPO_AUTOUPDATE = NO; RUN_SCRIPTS = NO;" > "${__RootfsDir}"/usr/local/etc/pkg.conf - echo "FreeBSD: { url: \"pkg+http://pkg.FreeBSD.org/\${ABI}/quarterly\", mirror_type: \"srv\", signature_type: \"fingerprints\", fingerprints: \"${__RootfsDir}/usr/share/keys/pkg\", enabled: yes }" > "${__RootfsDir}"/etc/pkg/FreeBSD.conf + echo "FreeBSD: { url: \"pkg+http://pkg.FreeBSD.org/\${ABI}/quarterly\", mirror_type: \"srv\", signature_type: \"fingerprints\", fingerprints: \"/usr/share/keys/pkg\", enabled: yes }" > "${__RootfsDir}"/etc/pkg/FreeBSD.conf mkdir -p "$__RootfsDir"/tmp # get and build package manager if [[ "$__hasWget" == 1 ]]; then @@ -681,7 +686,7 @@ elif [[ "$__CodeName" == "haiku" ]]; then ensureDownloadTool - echo "Downloading Haiku package tool" + echo "Downloading Haiku package tools" git clone https://github.com/haiku/haiku-toolchains-ubuntu --depth 1 "$__RootfsDir/tmp/script" if [[ "$__hasWget" == 1 ]]; then wget -O "$__RootfsDir/tmp/download/hosttools.zip" "$("$__RootfsDir/tmp/script/fetch.sh" --hosttools)" @@ -691,34 +696,42 @@ elif [[ "$__CodeName" == "haiku" ]]; then unzip -o "$__RootfsDir/tmp/download/hosttools.zip" -d "$__RootfsDir/tmp/bin" - DepotBaseUrl="https://depot.haiku-os.org/__api/v2/pkg/get-pkg" - HpkgBaseUrl="https://eu.hpkg.haiku-os.org/haiku/master/$__HaikuArch/current" + HaikuBaseUrl="https://eu.hpkg.haiku-os.org/haiku/master/$__HaikuArch/current" + HaikuPortsBaseUrl="https://eu.hpkg.haiku-os.org/haikuports/master/$__HaikuArch/current" + + echo "Downloading HaikuPorts package repository index..." + if [[ "$__hasWget" == 1 ]]; then + wget -P "$__RootfsDir/tmp/download" "$HaikuPortsBaseUrl/repo" + else + curl -SLO --create-dirs --output-dir "$__RootfsDir/tmp/download" "$HaikuPortsBaseUrl/repo" + fi - # Download Haiku packages echo "Downloading Haiku packages" read -ra array <<<"$__HaikuPackages" for package in "${array[@]}"; do echo "Downloading $package..." - # API documented here: https://github.com/haiku/haikudepotserver/blob/master/haikudepotserver-api2/src/main/resources/api2/pkg.yaml#L60 - # The schema here: https://github.com/haiku/haikudepotserver/blob/master/haikudepotserver-api2/src/main/resources/api2/pkg.yaml#L598 + hpkgFilename="$(LD_LIBRARY_PATH="$__RootfsDir/tmp/bin" "$__RootfsDir/tmp/bin/package_repo" list -f "$__RootfsDir/tmp/download/repo" | + grep -E "${package}-" | sort -V | tail -n 1 | xargs)" + if [ -z "$hpkgFilename" ]; then + >&2 echo "ERROR: package $package missing." + exit 1 + fi + echo "Resolved filename: $hpkgFilename..." + hpkgDownloadUrl="$HaikuPortsBaseUrl/packages/$hpkgFilename" if [[ "$__hasWget" == 1 ]]; then - hpkgDownloadUrl="$(wget -qO- --post-data '{"name":"'"$package"'","repositorySourceCode":"haikuports_'$__HaikuArch'","versionType":"LATEST","naturalLanguageCode":"en"}' \ - --header 'Content-Type:application/json' "$DepotBaseUrl" | jq -r '.result.versions[].hpkgDownloadURL')" wget -P "$__RootfsDir/tmp/download" "$hpkgDownloadUrl" else - hpkgDownloadUrl="$(curl -sSL -XPOST --data '{"name":"'"$package"'","repositorySourceCode":"haikuports_'$__HaikuArch'","versionType":"LATEST","naturalLanguageCode":"en"}' \ - --header 'Content-Type:application/json' "$DepotBaseUrl" | jq -r '.result.versions[].hpkgDownloadURL')" curl -SLO --create-dirs --output-dir "$__RootfsDir/tmp/download" "$hpkgDownloadUrl" fi done for package in haiku haiku_devel; do echo "Downloading $package..." if [[ "$__hasWget" == 1 ]]; then - hpkgVersion="$(wget -qO- "$HpkgBaseUrl" | sed -n 's/^.*version: "\([^"]*\)".*$/\1/p')" - wget -P "$__RootfsDir/tmp/download" "$HpkgBaseUrl/packages/$package-$hpkgVersion-1-$__HaikuArch.hpkg" + hpkgVersion="$(wget -qO- "$HaikuBaseUrl" | sed -n 's/^.*version: "\([^"]*\)".*$/\1/p')" + wget -P "$__RootfsDir/tmp/download" "$HaikuBaseUrl/packages/$package-$hpkgVersion-1-$__HaikuArch.hpkg" else - hpkgVersion="$(curl -sSL "$HpkgBaseUrl" | sed -n 's/^.*version: "\([^"]*\)".*$/\1/p')" - curl -SLO --create-dirs --output-dir "$__RootfsDir/tmp/download" "$HpkgBaseUrl/packages/$package-$hpkgVersion-1-$__HaikuArch.hpkg" + hpkgVersion="$(curl -sSL "$HaikuBaseUrl" | sed -n 's/^.*version: "\([^"]*\)".*$/\1/p')" + curl -SLO --create-dirs --output-dir "$__RootfsDir/tmp/download" "$HaikuBaseUrl/packages/$package-$hpkgVersion-1-$__HaikuArch.hpkg" fi done @@ -744,25 +757,67 @@ elif [[ "$__CodeName" == "haiku" ]]; then popd rm -rf "$__RootfsDir/tmp" elif [[ -n "$__CodeName" ]]; then + __Suites="$__CodeName $(for suite in $__UbuntuSuites; do echo -n "$__CodeName-$suite "; done)" + + if [[ "$__SkipEmulation" == "1" ]]; then + if [[ -z "$AR" ]]; then + if command -v ar &>/dev/null; then + AR="$(command -v ar)" + elif command -v llvm-ar &>/dev/null; then + AR="$(command -v llvm-ar)" + else + echo "Unable to find ar or llvm-ar on PATH, add them to PATH or set AR environment variable pointing to the available AR tool" + exit 1 + fi + fi + + PYTHON=${PYTHON_EXECUTABLE:-python3} + + # shellcheck disable=SC2086,SC2046 + echo running "$PYTHON" "$__CrossDir/install-debs.py" --arch "$__UbuntuArch" --mirror "$__UbuntuRepo" --rootfsdir "$__RootfsDir" --artool "$AR" \ + $(for suite in $__Suites; do echo -n "--suite $suite "; done) \ + $__UbuntuPackages + + # shellcheck disable=SC2086,SC2046 + "$PYTHON" "$__CrossDir/install-debs.py" --arch "$__UbuntuArch" --mirror "$__UbuntuRepo" --rootfsdir "$__RootfsDir" --artool "$AR" \ + $(for suite in $__Suites; do echo -n "--suite $suite "; done) \ + $__UbuntuPackages + exit 0 + fi + + __UpdateOptions= if [[ "$__SkipSigCheck" == "0" ]]; then __Keyring="$__Keyring --force-check-gpg" + else + __Keyring= + __UpdateOptions="--allow-unauthenticated --allow-insecure-repositories" fi # shellcheck disable=SC2086 echo running debootstrap "--variant=minbase" $__Keyring --arch "$__UbuntuArch" "$__CodeName" "$__RootfsDir" "$__UbuntuRepo" - debootstrap "--variant=minbase" $__Keyring --arch "$__UbuntuArch" "$__CodeName" "$__RootfsDir" "$__UbuntuRepo" + # shellcheck disable=SC2086 + if ! debootstrap "--variant=minbase" $__Keyring --arch "$__UbuntuArch" "$__CodeName" "$__RootfsDir" "$__UbuntuRepo"; then + echo "debootstrap failed! dumping debootstrap.log" + cat "$__RootfsDir/debootstrap/debootstrap.log" + exit 1 + fi + + rm -rf "$__RootfsDir"/etc/apt/*.{sources,list} "$__RootfsDir"/etc/apt/sources.list.d mkdir -p "$__RootfsDir/etc/apt/sources.list.d/" + + # shellcheck disable=SC2086 cat > "$__RootfsDir/etc/apt/sources.list.d/$__CodeName.sources" < token2) - (token1 < token2) + else: + return -1 if isinstance(token1, str) else 1 + + return len(tokens1) - len(tokens2) + +def compare_debian_versions(version1, version2): + """Compare two Debian package versions.""" + epoch1, upstream1, revision1 = parse_debian_version(version1) + epoch2, upstream2, revision2 = parse_debian_version(version2) + + if epoch1 != epoch2: + return epoch1 - epoch2 + + result = compare_upstream_version(upstream1, upstream2) + if result != 0: + return result + + return compare_upstream_version(revision1, revision2) + +def resolve_dependencies(packages, aliases, desired_packages): + """Recursively resolves dependencies for the desired packages.""" + resolved = [] + to_process = deque(desired_packages) + + while to_process: + current = to_process.popleft() + resolved_package = current if current in packages else aliases.get(current, [None])[0] + + if not resolved_package: + print(f"Error: Package '{current}' was not found in the available packages.") + sys.exit(1) + + if resolved_package not in resolved: + resolved.append(resolved_package) + + deps = packages.get(resolved_package, {}).get("Depends", "") + if deps: + deps = [dep.split(' ')[0] for dep in deps.split(', ') if dep] + for dep in deps: + if dep not in resolved and dep not in to_process and dep in packages: + to_process.append(dep) + + return resolved + +def parse_package_index(content): + """Parses the Packages.gz file and returns package information.""" + packages = {} + aliases = {} + entries = re.split(r'\n\n+', content) + + for entry in entries: + fields = dict(re.findall(r'^(\S+): (.+)$', entry, re.MULTILINE)) + if "Package" in fields: + package_name = fields["Package"] + version = fields.get("Version") + filename = fields.get("Filename") + depends = fields.get("Depends") + provides = fields.get("Provides", None) + + # Only update if package_name is not in packages or if the new version is higher + if package_name not in packages or compare_debian_versions(version, packages[package_name]["Version"]) > 0: + packages[package_name] = { + "Version": version, + "Filename": filename, + "Depends": depends + } + + # Update aliases if package provides any alternatives + if provides: + provides_list = [x.strip() for x in provides.split(",")] + for alias in provides_list: + # Strip version specifiers + alias_name = re.sub(r'\s*\(=.*\)', '', alias) + if alias_name not in aliases: + aliases[alias_name] = [] + if package_name not in aliases[alias_name]: + aliases[alias_name].append(package_name) + + return packages, aliases + +def install_packages(mirror, packages_info, aliases, tmp_dir, extract_dir, ar_tool, desired_packages): + """Downloads .deb files and extracts them.""" + resolved_packages = resolve_dependencies(packages_info, aliases, desired_packages) + print(f"Resolved packages (including dependencies): {resolved_packages}") + + packages_to_download = {} + + for pkg in resolved_packages: + if pkg in packages_info: + packages_to_download[pkg] = packages_info[pkg] + + if pkg in aliases: + for alias in aliases[pkg]: + if alias in packages_info: + packages_to_download[alias] = packages_info[alias] + + asyncio.run(download_deb_files_parallel(mirror, packages_to_download, tmp_dir)) + + package_to_deb_file_map = {} + for pkg in resolved_packages: + pkg_info = packages_info.get(pkg) + if pkg_info: + deb_filename = pkg_info.get("Filename") + if deb_filename: + deb_file_path = os.path.join(tmp_dir, os.path.basename(deb_filename)) + package_to_deb_file_map[pkg] = deb_file_path + + for pkg in reversed(resolved_packages): + deb_file = package_to_deb_file_map.get(pkg) + if deb_file and os.path.exists(deb_file): + extract_deb_file(deb_file, tmp_dir, extract_dir, ar_tool) + + print("All done!") + +def extract_deb_file(deb_file, tmp_dir, extract_dir, ar_tool): + """Extract .deb file contents""" + + os.makedirs(extract_dir, exist_ok=True) + + with tempfile.TemporaryDirectory(dir=tmp_dir) as tmp_subdir: + result = subprocess.run(f"{ar_tool} t {os.path.abspath(deb_file)}", cwd=tmp_subdir, check=True, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + tar_filename = None + for line in result.stdout.decode().splitlines(): + if line.startswith("data.tar"): + tar_filename = line.strip() + break + + if not tar_filename: + raise FileNotFoundError(f"Could not find 'data.tar.*' in {deb_file}.") + + tar_file_path = os.path.join(tmp_subdir, tar_filename) + print(f"Extracting {tar_filename} from {deb_file}..") + + subprocess.run(f"{ar_tool} p {os.path.abspath(deb_file)} {tar_filename} > {tar_file_path}", check=True, shell=True) + + file_extension = os.path.splitext(tar_file_path)[1].lower() + + if file_extension == ".xz": + mode = "r:xz" + elif file_extension == ".gz": + mode = "r:gz" + elif file_extension == ".zst": + # zstd is not supported by standard library yet + decompressed_tar_path = tar_file_path.replace(".zst", "") + with open(tar_file_path, "rb") as zst_file, open(decompressed_tar_path, "wb") as decompressed_file: + dctx = zstandard.ZstdDecompressor() + dctx.copy_stream(zst_file, decompressed_file) + + tar_file_path = decompressed_tar_path + mode = "r" + else: + raise ValueError(f"Unsupported compression format: {file_extension}") + + with tarfile.open(tar_file_path, mode) as tar: + tar.extractall(path=extract_dir, filter='fully_trusted') + +def finalize_setup(rootfsdir): + lib_dir = os.path.join(rootfsdir, 'lib') + usr_lib_dir = os.path.join(rootfsdir, 'usr', 'lib') + + if os.path.exists(lib_dir): + if os.path.islink(lib_dir): + os.remove(lib_dir) + else: + os.makedirs(usr_lib_dir, exist_ok=True) + + for item in os.listdir(lib_dir): + src = os.path.join(lib_dir, item) + dest = os.path.join(usr_lib_dir, item) + + if os.path.isdir(src): + shutil.copytree(src, dest, dirs_exist_ok=True) + else: + shutil.copy2(src, dest) + + shutil.rmtree(lib_dir) + + os.symlink(usr_lib_dir, lib_dir) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate rootfs for .NET runtime on Debian-like OS") + parser.add_argument("--distro", required=False, help="Distro name (e.g., debian, ubuntu, etc.)") + parser.add_argument("--arch", required=True, help="Architecture (e.g., amd64, loong64, etc.)") + parser.add_argument("--rootfsdir", required=True, help="Destination directory.") + parser.add_argument('--suite', required=True, action='append', help='Specify one or more repository suites to collect index data.') + parser.add_argument("--mirror", required=False, help="Mirror (e.g., http://ftp.debian.org/debian-ports etc.)") + parser.add_argument("--artool", required=False, default="ar", help="ar tool to extract debs (e.g., ar, llvm-ar etc.)") + parser.add_argument("packages", nargs="+", help="List of package names to be installed.") + + args = parser.parse_args() + + if args.mirror is None: + if args.distro == "ubuntu": + args.mirror = "http://archive.ubuntu.com/ubuntu" if args.arch in ["amd64", "i386"] else "http://ports.ubuntu.com/ubuntu-ports" + elif args.distro == "debian": + args.mirror = "http://ftp.debian.org/debian-ports" + else: + raise Exception("Unsupported distro") + + DESIRED_PACKAGES = args.packages + [ # base packages + "dpkg", + "busybox", + "libc-bin", + "base-files", + "base-passwd", + "debianutils" + ] + + print(f"Creating rootfs. rootfsdir: {args.rootfsdir}, distro: {args.distro}, arch: {args.arch}, suites: {args.suite}, mirror: {args.mirror}") + + package_index_content = asyncio.run(download_package_index_parallel(args.mirror, args.arch, args.suite)) + + packages_info, aliases = parse_package_index(package_index_content) + + with tempfile.TemporaryDirectory() as tmp_dir: + install_packages(args.mirror, packages_info, aliases, tmp_dir, args.rootfsdir, args.artool, DESIRED_PACKAGES) + + finalize_setup(args.rootfsdir) diff --git a/eng/common/cross/tizen-fetch.sh b/eng/common/cross/tizen-fetch.sh index 28936ceef3a..37c3a61f1de 100755 --- a/eng/common/cross/tizen-fetch.sh +++ b/eng/common/cross/tizen-fetch.sh @@ -156,13 +156,8 @@ fetch_tizen_pkgs() done } -if [ "$TIZEN_ARCH" == "riscv64" ]; then - BASE="Tizen-Base-RISCV" - UNIFIED="Tizen-Unified-RISCV" -else - BASE="Tizen-Base" - UNIFIED="Tizen-Unified" -fi +BASE="Tizen-Base" +UNIFIED="Tizen-Unified" Inform "Initialize ${TIZEN_ARCH} base" fetch_tizen_pkgs_init standard $BASE diff --git a/eng/common/cross/toolchain.cmake b/eng/common/cross/toolchain.cmake index 9a7ecfbd42c..0ff85cf0367 100644 --- a/eng/common/cross/toolchain.cmake +++ b/eng/common/cross/toolchain.cmake @@ -67,6 +67,13 @@ elseif(TARGET_ARCH_NAME STREQUAL "armv6") else() set(TOOLCHAIN "arm-linux-gnueabihf") endif() +elseif(TARGET_ARCH_NAME STREQUAL "loongarch64") + set(CMAKE_SYSTEM_PROCESSOR "loongarch64") + if(EXISTS ${CROSS_ROOTFS}/usr/lib/gcc/loongarch64-alpine-linux-musl) + set(TOOLCHAIN "loongarch64-alpine-linux-musl") + else() + set(TOOLCHAIN "loongarch64-linux-gnu") + endif() elseif(TARGET_ARCH_NAME STREQUAL "ppc64le") set(CMAKE_SYSTEM_PROCESSOR ppc64le) if(EXISTS ${CROSS_ROOTFS}/usr/lib/gcc/powerpc64le-alpine-linux-musl) @@ -118,7 +125,7 @@ elseif(TARGET_ARCH_NAME STREQUAL "x86") set(TIZEN_TOOLCHAIN "i586-tizen-linux-gnu") endif() else() - message(FATAL_ERROR "Arch is ${TARGET_ARCH_NAME}. Only arm, arm64, armel, armv6, ppc64le, riscv64, s390x, x64 and x86 are supported!") + message(FATAL_ERROR "Arch is ${TARGET_ARCH_NAME}. Only arm, arm64, armel, armv6, loongarch64, ppc64le, riscv64, s390x, x64 and x86 are supported!") endif() if(DEFINED ENV{TOOLCHAIN}) @@ -148,6 +155,25 @@ if(TIZEN) include_directories(SYSTEM ${TIZEN_TOOLCHAIN_PATH}/include/c++/${TIZEN_TOOLCHAIN}) endif() +function(locate_toolchain_exec exec var) + set(TOOLSET_PREFIX ${TOOLCHAIN}-) + string(TOUPPER ${exec} EXEC_UPPERCASE) + if(NOT "$ENV{CLR_${EXEC_UPPERCASE}}" STREQUAL "") + set(${var} "$ENV{CLR_${EXEC_UPPERCASE}}" PARENT_SCOPE) + return() + endif() + + find_program(EXEC_LOCATION_${exec} + NAMES + "${TOOLSET_PREFIX}${exec}${CLR_CMAKE_COMPILER_FILE_NAME_VERSION}" + "${TOOLSET_PREFIX}${exec}") + + if (EXEC_LOCATION_${exec} STREQUAL "EXEC_LOCATION_${exec}-NOTFOUND") + message(FATAL_ERROR "Unable to find toolchain executable. Name: ${exec}, Prefix: ${TOOLSET_PREFIX}.") + endif() + set(${var} ${EXEC_LOCATION_${exec}} PARENT_SCOPE) +endfunction() + if(ANDROID) if(TARGET_ARCH_NAME STREQUAL "arm") set(ANDROID_ABI armeabi-v7a) @@ -178,66 +204,24 @@ elseif(FREEBSD) set(CMAKE_MODULE_LINKER_FLAGS "${CMAKE_MODULE_LINKER_FLAGS} -fuse-ld=lld") elseif(ILLUMOS) set(CMAKE_SYSROOT "${CROSS_ROOTFS}") + set(CMAKE_SYSTEM_PREFIX_PATH "${CROSS_ROOTFS}") + set(CMAKE_C_STANDARD_LIBRARIES "${CMAKE_C_STANDARD_LIBRARIES} -lssp") + set(CMAKE_CXX_STANDARD_LIBRARIES "${CMAKE_CXX_STANDARD_LIBRARIES} -lssp") include_directories(SYSTEM ${CROSS_ROOTFS}/include) - set(TOOLSET_PREFIX ${TOOLCHAIN}-) - function(locate_toolchain_exec exec var) - string(TOUPPER ${exec} EXEC_UPPERCASE) - if(NOT "$ENV{CLR_${EXEC_UPPERCASE}}" STREQUAL "") - set(${var} "$ENV{CLR_${EXEC_UPPERCASE}}" PARENT_SCOPE) - return() - endif() - - find_program(EXEC_LOCATION_${exec} - NAMES - "${TOOLSET_PREFIX}${exec}${CLR_CMAKE_COMPILER_FILE_NAME_VERSION}" - "${TOOLSET_PREFIX}${exec}") - - if (EXEC_LOCATION_${exec} STREQUAL "EXEC_LOCATION_${exec}-NOTFOUND") - message(FATAL_ERROR "Unable to find toolchain executable. Name: ${exec}, Prefix: ${TOOLSET_PREFIX}.") - endif() - set(${var} ${EXEC_LOCATION_${exec}} PARENT_SCOPE) - endfunction() - - set(CMAKE_SYSTEM_PREFIX_PATH "${CROSS_ROOTFS}") - locate_toolchain_exec(gcc CMAKE_C_COMPILER) locate_toolchain_exec(g++ CMAKE_CXX_COMPILER) - - set(CMAKE_C_STANDARD_LIBRARIES "${CMAKE_C_STANDARD_LIBRARIES} -lssp") - set(CMAKE_CXX_STANDARD_LIBRARIES "${CMAKE_CXX_STANDARD_LIBRARIES} -lssp") elseif(HAIKU) set(CMAKE_SYSROOT "${CROSS_ROOTFS}") set(CMAKE_PROGRAM_PATH "${CMAKE_PROGRAM_PATH};${CROSS_ROOTFS}/cross-tools-x86_64/bin") - - set(TOOLSET_PREFIX ${TOOLCHAIN}-) - function(locate_toolchain_exec exec var) - string(TOUPPER ${exec} EXEC_UPPERCASE) - if(NOT "$ENV{CLR_${EXEC_UPPERCASE}}" STREQUAL "") - set(${var} "$ENV{CLR_${EXEC_UPPERCASE}}" PARENT_SCOPE) - return() - endif() - - find_program(EXEC_LOCATION_${exec} - NAMES - "${TOOLSET_PREFIX}${exec}${CLR_CMAKE_COMPILER_FILE_NAME_VERSION}" - "${TOOLSET_PREFIX}${exec}") - - if (EXEC_LOCATION_${exec} STREQUAL "EXEC_LOCATION_${exec}-NOTFOUND") - message(FATAL_ERROR "Unable to find toolchain executable. Name: ${exec}, Prefix: ${TOOLSET_PREFIX}.") - endif() - set(${var} ${EXEC_LOCATION_${exec}} PARENT_SCOPE) - endfunction() - set(CMAKE_SYSTEM_PREFIX_PATH "${CROSS_ROOTFS}") + set(CMAKE_C_STANDARD_LIBRARIES "${CMAKE_C_STANDARD_LIBRARIES} -lssp") + set(CMAKE_CXX_STANDARD_LIBRARIES "${CMAKE_CXX_STANDARD_LIBRARIES} -lssp") locate_toolchain_exec(gcc CMAKE_C_COMPILER) locate_toolchain_exec(g++ CMAKE_CXX_COMPILER) - set(CMAKE_C_STANDARD_LIBRARIES "${CMAKE_C_STANDARD_LIBRARIES} -lssp") - set(CMAKE_CXX_STANDARD_LIBRARIES "${CMAKE_CXX_STANDARD_LIBRARIES} -lssp") - # let CMake set up the correct search paths include(Platform/Haiku) else() @@ -307,7 +291,7 @@ endif() # Specify compile options -if((TARGET_ARCH_NAME MATCHES "^(arm|arm64|armel|armv6|ppc64le|riscv64|s390x|x64|x86)$" AND NOT ANDROID AND NOT FREEBSD) OR ILLUMOS OR HAIKU) +if((TARGET_ARCH_NAME MATCHES "^(arm|arm64|armel|armv6|loongarch64|ppc64le|riscv64|s390x|x64|x86)$" AND NOT ANDROID AND NOT FREEBSD) OR ILLUMOS OR HAIKU) set(CMAKE_C_COMPILER_TARGET ${TOOLCHAIN}) set(CMAKE_CXX_COMPILER_TARGET ${TOOLCHAIN}) set(CMAKE_ASM_COMPILER_TARGET ${TOOLCHAIN}) diff --git a/eng/common/darc-init.sh b/eng/common/darc-init.sh index 36dbd45e1ce..e889f439b8d 100755 --- a/eng/common/darc-init.sh +++ b/eng/common/darc-init.sh @@ -68,7 +68,7 @@ function InstallDarcCli { fi fi - local arcadeServicesSource="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json" + local arcadeServicesSource="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json" echo "Installing Darc CLI version $darcVersion..." echo "You may need to restart your command shell if this is the first dotnet tool you have installed." diff --git a/eng/common/dotnet.cmd b/eng/common/dotnet.cmd new file mode 100644 index 00000000000..527fa4bb38f --- /dev/null +++ b/eng/common/dotnet.cmd @@ -0,0 +1,7 @@ +@echo off + +:: This script is used to install the .NET SDK. +:: It will also invoke the SDK with any provided arguments. + +powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0dotnet.ps1""" %*" +exit /b %ErrorLevel% diff --git a/eng/common/dotnet.ps1 b/eng/common/dotnet.ps1 new file mode 100644 index 00000000000..45e5676c9eb --- /dev/null +++ b/eng/common/dotnet.ps1 @@ -0,0 +1,11 @@ +# This script is used to install the .NET SDK. +# It will also invoke the SDK with any provided arguments. + +. $PSScriptRoot\tools.ps1 +$dotnetRoot = InitializeDotNetCli -install:$true + +# Invoke acquired SDK with args if they are provided +if ($args.count -gt 0) { + $env:DOTNET_NOLOGO=1 + & "$dotnetRoot\dotnet.exe" $args +} diff --git a/eng/common/dotnet.sh b/eng/common/dotnet.sh new file mode 100644 index 00000000000..2ef68235675 --- /dev/null +++ b/eng/common/dotnet.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# This script is used to install the .NET SDK. +# It will also invoke the SDK with any provided arguments. + +source="${BASH_SOURCE[0]}" +# resolve $SOURCE until the file is no longer a symlink +while [[ -h $source ]]; do + scriptroot="$( cd -P "$( dirname "$source" )" && pwd )" + source="$(readlink "$source")" + + # if $source was a relative symlink, we need to resolve it relative to the path where the + # symlink file was located + [[ $source != /* ]] && source="$scriptroot/$source" +done +scriptroot="$( cd -P "$( dirname "$source" )" && pwd )" + +source $scriptroot/tools.sh +InitializeDotNetCli true # install + +# Invoke acquired SDK with args if they are provided +if [[ $# > 0 ]]; then + __dotnetDir=${_InitializeDotNetCli} + dotnetPath=${__dotnetDir}/dotnet + ${dotnetPath} "$@" +fi diff --git a/eng/common/generate-locproject.ps1 b/eng/common/generate-locproject.ps1 index 524aaa57f2b..fa1cdc2b300 100644 --- a/eng/common/generate-locproject.ps1 +++ b/eng/common/generate-locproject.ps1 @@ -33,15 +33,27 @@ $jsonTemplateFiles | ForEach-Object { $jsonWinformsTemplateFiles = Get-ChildItem -Recurse -Path "$SourcesDirectory" | Where-Object { $_.FullName -Match "en\\strings\.json" } # current winforms pattern +$wxlFilesV3 = @() +$wxlFilesV5 = @() $wxlFiles = Get-ChildItem -Recurse -Path "$SourcesDirectory" | Where-Object { $_.FullName -Match "\\.+\.wxl" -And -Not( $_.Directory.Name -Match "\d{4}" ) } # localized files live in four digit lang ID directories; this excludes them if (-not $wxlFiles) { $wxlEnFiles = Get-ChildItem -Recurse -Path "$SourcesDirectory" | Where-Object { $_.FullName -Match "\\1033\\.+\.wxl" } # pick up en files (1033 = en) specifically so we can copy them to use as the neutral xlf files if ($wxlEnFiles) { - $wxlFiles = @() - $wxlEnFiles | ForEach-Object { - $destinationFile = "$($_.Directory.Parent.FullName)\$($_.Name)" - $wxlFiles += Copy-Item "$($_.FullName)" -Destination $destinationFile -PassThru - } + $wxlFiles = @() + $wxlEnFiles | ForEach-Object { + $destinationFile = "$($_.Directory.Parent.FullName)\$($_.Name)" + $content = Get-Content $_.FullName -Raw + + # Split files on schema to select different parser settings in the generated project. + if ($content -like "*http://wixtoolset.org/schemas/v4/wxl*") + { + $wxlFilesV5 += Copy-Item $_.FullName -Destination $destinationFile -PassThru + } + elseif ($content -like "*http://schemas.microsoft.com/wix/2006/localization*") + { + $wxlFilesV3 += Copy-Item $_.FullName -Destination $destinationFile -PassThru + } + } } } @@ -114,7 +126,32 @@ $locJson = @{ CloneLanguageSet = "WiX_CloneLanguages" LssFiles = @( "wxl_loc.lss" ) LocItems = @( - $wxlFiles | ForEach-Object { + $wxlFilesV3 | ForEach-Object { + $outputPath = "$($_.Directory.FullName | Resolve-Path -Relative)\" + $continue = $true + foreach ($exclusion in $exclusions.Exclusions) { + if ($_.FullName.Contains($exclusion)) { + $continue = $false + } + } + $sourceFile = ($_.FullName | Resolve-Path -Relative) + if ($continue) + { + return @{ + SourceFile = $sourceFile + CopyOption = "LangIDOnPath" + OutputPath = $outputPath + } + } + } + ) + }, + @{ + LanguageSet = $LanguageSet + CloneLanguageSet = "WiX_CloneLanguages" + LssFiles = @( "P210WxlSchemaV4.lss" ) + LocItems = @( + $wxlFilesV5 | ForEach-Object { $outputPath = "$($_.Directory.FullName | Resolve-Path -Relative)\" $continue = $true foreach ($exclusion in $exclusions.Exclusions) { diff --git a/eng/common/native/install-dependencies.sh b/eng/common/native/install-dependencies.sh new file mode 100644 index 00000000000..477a44f335b --- /dev/null +++ b/eng/common/native/install-dependencies.sh @@ -0,0 +1,62 @@ +#!/bin/sh + +set -e + +# This is a simple script primarily used for CI to install necessary dependencies +# +# Usage: +# +# ./install-dependencies.sh + +os="$(echo "$1" | tr "[:upper:]" "[:lower:]")" + +if [ -z "$os" ]; then + . "$(dirname "$0")"/init-os-and-arch.sh +fi + +case "$os" in + linux) + if [ -e /etc/os-release ]; then + . /etc/os-release + fi + + if [ "$ID" = "debian" ] || [ "$ID_LIKE" = "debian" ]; then + apt update + + apt install -y build-essential gettext locales cmake llvm clang lld lldb liblldb-dev libunwind8-dev libicu-dev liblttng-ust-dev \ + libssl-dev libkrb5-dev pigz cpio + + localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 + elif [ "$ID" = "fedora" ] || [ "$ID" = "rhel" ] || [ "$ID" = "azurelinux" ]; then + pkg_mgr="$(command -v tdnf 2>/dev/null || command -v dnf)" + $pkg_mgr install -y cmake llvm lld lldb clang python curl libicu-devel openssl-devel krb5-devel lttng-ust-devel pigz cpio + elif [ "$ID" = "alpine" ]; then + apk add build-base cmake bash curl clang llvm-dev lld lldb krb5-dev lttng-ust-dev icu-dev openssl-dev pigz cpio + else + echo "Unsupported distro. distro: $ID" + exit 1 + fi + ;; + + osx|maccatalyst|ios|iossimulator|tvos|tvossimulator) + echo "Installed xcode version: $(xcode-select -p)" + + export HOMEBREW_NO_INSTALL_CLEANUP=1 + export HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 + # Skip brew update for now, see https://github.com/actions/setup-python/issues/577 + # brew update --preinstall + brew bundle --no-upgrade --file=- < Msbuild engine to use to run build ('dotnet', 'vs', or unspecified)." + Write-Host " -excludeCIBinaryLog When running on CI, allow no binary log (short: -nobl)" Write-Host "" Write-Host "Command line arguments not listed above are passed thru to msbuild." } @@ -34,10 +39,11 @@ function Print-Usage() { function Build([string]$target) { $logSuffix = if ($target -eq 'Execute') { '' } else { ".$target" } $log = Join-Path $LogDir "$task$logSuffix.binlog" + $binaryLogArg = if ($binaryLog) { "/bl:$log" } else { "" } $outputPath = Join-Path $ToolsetDir "$task\" MSBuild $taskProject ` - /bl:$log ` + $binaryLogArg ` /t:$target ` /p:Configuration=$configuration ` /p:RepoRoot=$RepoRoot ` @@ -64,7 +70,7 @@ try { $GlobalJson.tools | Add-Member -Name "vs" -Value (ConvertFrom-Json "{ `"version`": `"16.5`" }") -MemberType NoteProperty } if( -not ($GlobalJson.tools.PSObject.Properties.Name -match "xcopy-msbuild" )) { - $GlobalJson.tools | Add-Member -Name "xcopy-msbuild" -Value "17.12.0" -MemberType NoteProperty + $GlobalJson.tools | Add-Member -Name "xcopy-msbuild" -Value "18.0.0" -MemberType NoteProperty } if ($GlobalJson.tools."xcopy-msbuild".Trim() -ine "none") { $xcopyMSBuildToolsFolder = InitializeXCopyMSBuild $GlobalJson.tools."xcopy-msbuild" -install $true diff --git a/eng/common/sdk-task.sh b/eng/common/sdk-task.sh new file mode 100644 index 00000000000..3270f83fa9a --- /dev/null +++ b/eng/common/sdk-task.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +show_usage() { + echo "Common settings:" + echo " --task Name of Arcade task (name of a project in SdkTasks directory of the Arcade SDK package)" + echo " --restore Restore dependencies" + echo " --verbosity Msbuild verbosity: q[uiet], m[inimal], n[ormal], d[etailed], and diag[nostic]" + echo " --help Print help and exit" + echo "" + + echo "Advanced settings:" + echo " --excludeCIBinarylog Don't output binary log (short: -nobl)" + echo " --noWarnAsError Do not warn as error" + echo "" + echo "Command line arguments not listed above are passed thru to msbuild." +} + +source="${BASH_SOURCE[0]}" + +# resolve $source until the file is no longer a symlink +while [[ -h "$source" ]]; do + scriptroot="$( cd -P "$( dirname "$source" )" && pwd )" + source="$(readlink "$source")" + # if $source was a relative symlink, we need to resolve it relative to the path where the + # symlink file was located + [[ $source != /* ]] && source="$scriptroot/$source" +done +scriptroot="$( cd -P "$( dirname "$source" )" && pwd )" + +Build() { + local target=$1 + local log_suffix="" + [[ "$target" != "Execute" ]] && log_suffix=".$target" + local log="$log_dir/$task$log_suffix.binlog" + local binaryLogArg="" + [[ $binary_log == true ]] && binaryLogArg="/bl:$log" + local output_path="$toolset_dir/$task/" + + MSBuild "$taskProject" \ + $binaryLogArg \ + /t:"$target" \ + /p:Configuration="$configuration" \ + /p:RepoRoot="$repo_root" \ + /p:BaseIntermediateOutputPath="$output_path" \ + /v:"$verbosity" \ + $properties +} + +binary_log=true +configuration="Debug" +verbosity="minimal" +exclude_ci_binary_log=false +restore=false +help=false +properties='' +warnAsError=true + +while (($# > 0)); do + lowerI="$(echo $1 | tr "[:upper:]" "[:lower:]")" + case $lowerI in + --task) + task=$2 + shift 2 + ;; + --restore) + restore=true + shift 1 + ;; + --verbosity) + verbosity=$2 + shift 2 + ;; + --excludecibinarylog|--nobl) + binary_log=false + exclude_ci_binary_log=true + shift 1 + ;; + --noWarnAsError) + warnAsError=false + shift 1 + ;; + --help) + help=true + shift 1 + ;; + *) + properties="$properties $1" + shift 1 + ;; + esac +done + +ci=true + +if $help; then + show_usage + exit 0 +fi + +. "$scriptroot/tools.sh" +InitializeToolset + +if [[ -z "$task" ]]; then + Write-PipelineTelemetryError -Category 'Task' -Name 'MissingTask' -Message "Missing required parameter '-task '" + ExitWithExitCode 1 +fi + +taskProject=$(GetSdkTaskProject "$task") +if [[ ! -e "$taskProject" ]]; then + Write-PipelineTelemetryError -Category 'Task' -Name 'UnknownTask' -Message "Unknown task: $task" + ExitWithExitCode 1 +fi + +if $restore; then + Build "Restore" +fi + +Build "Execute" + + +ExitWithExitCode 0 diff --git a/eng/common/sdl/packages.config b/eng/common/sdl/packages.config index 4585cfd6bba..e5f543ea68c 100644 --- a/eng/common/sdl/packages.config +++ b/eng/common/sdl/packages.config @@ -1,4 +1,4 @@ - + diff --git a/eng/common/templates-official/job/job.yml b/eng/common/templates-official/job/job.yml index 81ea7a261f2..92a0664f564 100644 --- a/eng/common/templates-official/job/job.yml +++ b/eng/common/templates-official/job/job.yml @@ -31,6 +31,7 @@ jobs: PathtoPublish: '$(Build.ArtifactStagingDirectory)/artifacts' ArtifactName: ${{ coalesce(parameters.artifacts.publish.artifacts.name , 'Artifacts_$(Agent.Os)_$(_BuildConfig)') }} condition: always() + retryCountOnTaskFailure: 10 # for any logs being locked continueOnError: true - ${{ if and(ne(parameters.artifacts.publish.logs, 'false'), ne(parameters.artifacts.publish.logs, '')) }}: - output: pipelineArtifact @@ -39,6 +40,7 @@ jobs: displayName: 'Publish logs' continueOnError: true condition: always() + retryCountOnTaskFailure: 10 # for any logs being locked sbomEnabled: false # we don't need SBOM for logs - ${{ if eq(parameters.enablePublishBuildArtifacts, true) }}: @@ -46,7 +48,7 @@ jobs: displayName: Publish Logs PathtoPublish: '$(Build.ArtifactStagingDirectory)/artifacts/log/$(_BuildConfig)' publishLocation: Container - ArtifactName: ${{ coalesce(parameters.enablePublishBuildArtifacts.artifactName, '$(Agent.Os)_$(Agent.JobName)' ) }} + ArtifactName: ${{ coalesce(parameters.enablePublishBuildArtifacts.artifactName, '$(Agent.Os)_$(Agent.JobName)_Attempt$(System.JobAttempt)' ) }} continueOnError: true condition: always() sbomEnabled: false # we don't need SBOM for logs diff --git a/eng/common/templates-official/steps/publish-build-artifacts.yml b/eng/common/templates-official/steps/publish-build-artifacts.yml index 100a3fc9849..fcf6637b2eb 100644 --- a/eng/common/templates-official/steps/publish-build-artifacts.yml +++ b/eng/common/templates-official/steps/publish-build-artifacts.yml @@ -24,6 +24,10 @@ parameters: - name: is1ESPipeline type: boolean default: true + +- name: retryCountOnTaskFailure + type: string + default: 10 steps: - ${{ if ne(parameters.is1ESPipeline, true) }}: @@ -38,4 +42,5 @@ steps: PathtoPublish: ${{ parameters.pathToPublish }} ${{ if parameters.artifactName }}: ArtifactName: ${{ parameters.artifactName }} - + ${{ if parameters.retryCountOnTaskFailure }}: + retryCountOnTaskFailure: ${{ parameters.retryCountOnTaskFailure }} diff --git a/eng/common/templates-official/steps/source-index-stage1-publish.yml b/eng/common/templates-official/steps/source-index-stage1-publish.yml new file mode 100644 index 00000000000..9b8b80942b5 --- /dev/null +++ b/eng/common/templates-official/steps/source-index-stage1-publish.yml @@ -0,0 +1,7 @@ +steps: +- template: /eng/common/core-templates/steps/source-index-stage1-publish.yml + parameters: + is1ESPipeline: true + + ${{ each parameter in parameters }}: + ${{ parameter.key }}: ${{ parameter.value }} diff --git a/eng/common/templates/job/job.yml b/eng/common/templates/job/job.yml index 5bdd3dd85fd..238fa0818f7 100644 --- a/eng/common/templates/job/job.yml +++ b/eng/common/templates/job/job.yml @@ -46,6 +46,7 @@ jobs: artifactName: ${{ coalesce(parameters.artifacts.publish.artifacts.name , 'Artifacts_$(Agent.Os)_$(_BuildConfig)') }} continueOnError: true condition: always() + retryCountOnTaskFailure: 10 # for any logs being locked - ${{ if and(ne(parameters.artifacts.publish.logs, 'false'), ne(parameters.artifacts.publish.logs, '')) }}: - template: /eng/common/core-templates/steps/publish-pipeline-artifacts.yml parameters: @@ -56,6 +57,7 @@ jobs: displayName: 'Publish logs' continueOnError: true condition: always() + retryCountOnTaskFailure: 10 # for any logs being locked sbomEnabled: false # we don't need SBOM for logs - ${{ if ne(parameters.enablePublishBuildArtifacts, 'false') }}: @@ -66,7 +68,7 @@ jobs: displayName: Publish Logs pathToPublish: '$(Build.ArtifactStagingDirectory)/artifacts/log/$(_BuildConfig)' publishLocation: Container - artifactName: ${{ coalesce(parameters.enablePublishBuildArtifacts.artifactName, '$(Agent.Os)_$(Agent.JobName)' ) }} + artifactName: ${{ coalesce(parameters.enablePublishBuildArtifacts.artifactName, '$(Agent.Os)_$(Agent.JobName)_Attempt$(System.JobAttempt)' ) }} continueOnError: true condition: always() diff --git a/eng/common/templates/steps/publish-build-artifacts.yml b/eng/common/templates/steps/publish-build-artifacts.yml index 6428a98dfef..605e602e94d 100644 --- a/eng/common/templates/steps/publish-build-artifacts.yml +++ b/eng/common/templates/steps/publish-build-artifacts.yml @@ -25,6 +25,10 @@ parameters: type: string default: 'Container' +- name: retryCountOnTaskFailure + type: string + default: 10 + steps: - ${{ if eq(parameters.is1ESPipeline, true) }}: - 'eng/common/templates cannot be referenced from a 1ES managed template': error @@ -37,4 +41,6 @@ steps: PublishLocation: ${{ parameters.publishLocation }} PathtoPublish: ${{ parameters.pathToPublish }} ${{ if parameters.artifactName }}: - ArtifactName: ${{ parameters.artifactName }} \ No newline at end of file + ArtifactName: ${{ parameters.artifactName }} + ${{ if parameters.retryCountOnTaskFailure }}: + retryCountOnTaskFailure: ${{ parameters.retryCountOnTaskFailure }} diff --git a/eng/common/templates/steps/source-index-stage1-publish.yml b/eng/common/templates/steps/source-index-stage1-publish.yml new file mode 100644 index 00000000000..182cec33a7b --- /dev/null +++ b/eng/common/templates/steps/source-index-stage1-publish.yml @@ -0,0 +1,7 @@ +steps: +- template: /eng/common/core-templates/steps/source-index-stage1-publish.yml + parameters: + is1ESPipeline: false + + ${{ each parameter in parameters }}: + ${{ parameter.key }}: ${{ parameter.value }} diff --git a/eng/common/templates/steps/vmr-sync.yml b/eng/common/templates/steps/vmr-sync.yml new file mode 100644 index 00000000000..eb619c50268 --- /dev/null +++ b/eng/common/templates/steps/vmr-sync.yml @@ -0,0 +1,186 @@ +### These steps synchronize new code from product repositories into the VMR (https://github.com/dotnet/dotnet). +### They initialize the darc CLI and pull the new updates. +### Changes are applied locally onto the already cloned VMR (located in $vmrPath). + +parameters: +- name: targetRef + displayName: Target revision in dotnet/ to synchronize + type: string + default: $(Build.SourceVersion) + +- name: vmrPath + displayName: Path where the dotnet/dotnet is checked out to + type: string + default: $(Agent.BuildDirectory)/vmr + +- name: additionalSyncs + displayName: Optional list of package names whose repo's source will also be synchronized in the local VMR, e.g. NuGet.Protocol + type: object + default: [] + +steps: +- checkout: vmr + displayName: Clone dotnet/dotnet + path: vmr + clean: true + +- checkout: self + displayName: Clone $(Build.Repository.Name) + path: repo + fetchDepth: 0 + +# This step is needed so that when we get a detached HEAD / shallow clone, +# we still pull the commit into the temporary repo clone to use it during the sync. +# Also unshallow the clone so that forwardflow command would work. +- script: | + git branch repo-head + git rev-parse HEAD + displayName: Label PR commit + workingDirectory: $(Agent.BuildDirectory)/repo + +- script: | + git config --global user.name "dotnet-maestro[bot]" + git config --global user.email "dotnet-maestro[bot]@users.noreply.github.com" + displayName: Set git author to dotnet-maestro[bot] + workingDirectory: ${{ parameters.vmrPath }} + +- script: | + ./eng/common/vmr-sync.sh \ + --vmr ${{ parameters.vmrPath }} \ + --tmp $(Agent.TempDirectory) \ + --azdev-pat '$(dn-bot-all-orgs-code-r)' \ + --ci \ + --debug + + if [ "$?" -ne 0 ]; then + echo "##vso[task.logissue type=error]Failed to synchronize the VMR" + exit 1 + fi + displayName: Sync repo into VMR (Unix) + condition: ne(variables['Agent.OS'], 'Windows_NT') + workingDirectory: $(Agent.BuildDirectory)/repo + +- script: | + git config --global diff.astextplain.textconv echo + git config --system core.longpaths true + displayName: Configure Windows git (longpaths, astextplain) + condition: eq(variables['Agent.OS'], 'Windows_NT') + +- powershell: | + ./eng/common/vmr-sync.ps1 ` + -vmr ${{ parameters.vmrPath }} ` + -tmp $(Agent.TempDirectory) ` + -azdevPat '$(dn-bot-all-orgs-code-r)' ` + -ci ` + -debugOutput + + if ($LASTEXITCODE -ne 0) { + echo "##vso[task.logissue type=error]Failed to synchronize the VMR" + exit 1 + } + displayName: Sync repo into VMR (Windows) + condition: eq(variables['Agent.OS'], 'Windows_NT') + workingDirectory: $(Agent.BuildDirectory)/repo + +- ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: + - task: CopyFiles@2 + displayName: Collect failed patches + condition: failed() + inputs: + SourceFolder: '$(Agent.TempDirectory)' + Contents: '*.patch' + TargetFolder: '$(Build.ArtifactStagingDirectory)/FailedPatches' + + - publish: '$(Build.ArtifactStagingDirectory)/FailedPatches' + artifact: $(System.JobDisplayName)_FailedPatches + displayName: Upload failed patches + condition: failed() + +- ${{ each assetName in parameters.additionalSyncs }}: + # The vmr-sync script ends up staging files in the local VMR so we have to commit those + - script: + git commit --allow-empty -am "Forward-flow $(Build.Repository.Name)" + displayName: Commit local VMR changes + workingDirectory: ${{ parameters.vmrPath }} + + - script: | + set -ex + + echo "Searching for details of asset ${{ assetName }}..." + + # Use darc to get dependencies information + dependencies=$(./.dotnet/dotnet darc get-dependencies --name '${{ assetName }}' --ci) + + # Extract repository URL and commit hash + repository=$(echo "$dependencies" | grep 'Repo:' | sed 's/Repo:[[:space:]]*//' | head -1) + + if [ -z "$repository" ]; then + echo "##vso[task.logissue type=error]Asset ${{ assetName }} not found in the dependency list" + exit 1 + fi + + commit=$(echo "$dependencies" | grep 'Commit:' | sed 's/Commit:[[:space:]]*//' | head -1) + + echo "Updating the VMR from $repository / $commit..." + cd .. + git clone $repository ${{ assetName }} + cd ${{ assetName }} + git checkout $commit + git branch "sync/$commit" + + ./eng/common/vmr-sync.sh \ + --vmr ${{ parameters.vmrPath }} \ + --tmp $(Agent.TempDirectory) \ + --azdev-pat '$(dn-bot-all-orgs-code-r)' \ + --ci \ + --debug + + if [ "$?" -ne 0 ]; then + echo "##vso[task.logissue type=error]Failed to synchronize the VMR" + exit 1 + fi + displayName: Sync ${{ assetName }} into (Unix) + condition: ne(variables['Agent.OS'], 'Windows_NT') + workingDirectory: $(Agent.BuildDirectory)/repo + + - powershell: | + $ErrorActionPreference = 'Stop' + + Write-Host "Searching for details of asset ${{ assetName }}..." + + $dependencies = .\.dotnet\dotnet darc get-dependencies --name '${{ assetName }}' --ci + + $repository = $dependencies | Select-String -Pattern 'Repo:\s+([^\s]+)' | Select-Object -First 1 + $repository -match 'Repo:\s+([^\s]+)' | Out-Null + $repository = $matches[1] + + if ($repository -eq $null) { + Write-Error "Asset ${{ assetName }} not found in the dependency list" + exit 1 + } + + $commit = $dependencies | Select-String -Pattern 'Commit:\s+([^\s]+)' | Select-Object -First 1 + $commit -match 'Commit:\s+([^\s]+)' | Out-Null + $commit = $matches[1] + + Write-Host "Updating the VMR from $repository / $commit..." + cd .. + git clone $repository ${{ assetName }} + cd ${{ assetName }} + git checkout $commit + git branch "sync/$commit" + + .\eng\common\vmr-sync.ps1 ` + -vmr ${{ parameters.vmrPath }} ` + -tmp $(Agent.TempDirectory) ` + -azdevPat '$(dn-bot-all-orgs-code-r)' ` + -ci ` + -debugOutput + + if ($LASTEXITCODE -ne 0) { + echo "##vso[task.logissue type=error]Failed to synchronize the VMR" + exit 1 + } + displayName: Sync ${{ assetName }} into (Windows) + condition: ne(variables['Agent.OS'], 'Windows_NT') + workingDirectory: $(Agent.BuildDirectory)/repo diff --git a/eng/common/templates/vmr-build-pr.yml b/eng/common/templates/vmr-build-pr.yml new file mode 100644 index 00000000000..2f3694fa132 --- /dev/null +++ b/eng/common/templates/vmr-build-pr.yml @@ -0,0 +1,43 @@ +# This pipeline is used for running the VMR verification of the PR changes in repo-level PRs. +# +# It will run a full set of verification jobs defined in: +# https://github.com/dotnet/dotnet/blob/10060d128e3f470e77265f8490f5e4f72dae738e/eng/pipelines/templates/stages/vmr-build.yml#L27-L38 +# +# For repos that do not need to run the full set, you would do the following: +# +# 1. Copy this YML file to a repo-specific location, i.e. outside of eng/common. +# +# 2. Add `verifications` parameter to VMR template reference +# +# Examples: +# - For source-build stage 1 verification, add the following: +# verifications: [ "source-build-stage1" ] +# +# - For Windows only verifications, add the following: +# verifications: [ "unified-build-windows-x64", "unified-build-windows-x86" ] + +trigger: none +pr: none + +variables: +- template: /eng/common/templates/variables/pool-providers.yml@self + +- name: skipComponentGovernanceDetection # we run CG on internal builds only + value: true + +- name: Codeql.Enabled # we run CodeQL on internal builds only + value: false + +resources: + repositories: + - repository: vmr + type: github + name: dotnet/dotnet + endpoint: dotnet + ref: refs/heads/main # Set to whatever VMR branch the PR build should insert into + +stages: +- template: /eng/pipelines/templates/stages/vmr-build.yml@vmr + parameters: + isBuiltFromVmr: false + scope: lite diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index a06513a5940..977a2d4b103 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -65,10 +65,8 @@ $ErrorActionPreference = 'Stop' # Base-64 encoded SAS token that has permission to storage container described by $runtimeSourceFeed [string]$runtimeSourceFeedKey = if (Test-Path variable:runtimeSourceFeedKey) { $runtimeSourceFeedKey } else { $null } -# True if the build is a product build -[bool]$productBuild = if (Test-Path variable:productBuild) { $productBuild } else { $false } - -[String[]]$properties = if (Test-Path variable:properties) { $properties } else { @() } +# True when the build is running within the VMR. +[bool]$fromVMR = if (Test-Path variable:fromVMR) { $fromVMR } else { $false } function Create-Directory ([string[]] $path) { New-Item -Path $path -Force -ItemType 'Directory' | Out-Null @@ -259,7 +257,20 @@ function Retry($downloadBlock, $maxRetries = 5) { function GetDotNetInstallScript([string] $dotnetRoot) { $installScript = Join-Path $dotnetRoot 'dotnet-install.ps1' + $shouldDownload = $false + if (!(Test-Path $installScript)) { + $shouldDownload = $true + } else { + # Check if the script is older than 30 days + $fileAge = (Get-Date) - (Get-Item $installScript).LastWriteTime + if ($fileAge.Days -gt 30) { + Write-Host "Existing install script is too old, re-downloading..." + $shouldDownload = $true + } + } + + if ($shouldDownload) { Create-Directory $dotnetRoot $ProgressPreference = 'SilentlyContinue' # Don't display the console progress UI - it's a huge perf hit $uri = "https://builds.dotnet.microsoft.com/dotnet/scripts/$dotnetInstallScriptVersion/dotnet-install.ps1" @@ -383,8 +394,8 @@ function InitializeVisualStudioMSBuild([bool]$install, [object]$vsRequirements = # If the version of msbuild is going to be xcopied, # use this version. Version matches a package here: - # https://dev.azure.com/dnceng/public/_artifacts/feed/dotnet-eng/NuGet/Microsoft.DotNet.Arcade.MSBuild.Xcopy/versions/17.12.0 - $defaultXCopyMSBuildVersion = '17.12.0' + # https://dev.azure.com/dnceng/public/_artifacts/feed/dotnet-eng/NuGet/Microsoft.DotNet.Arcade.MSBuild.Xcopy/versions/18.0.0 + $defaultXCopyMSBuildVersion = '18.0.0' if (!$vsRequirements) { if (Get-Member -InputObject $GlobalJson.tools -Name 'vs') { @@ -533,7 +544,8 @@ function LocateVisualStudio([object]$vsRequirements = $null){ if (Get-Member -InputObject $GlobalJson.tools -Name 'vswhere') { $vswhereVersion = $GlobalJson.tools.vswhere } else { - $vswhereVersion = '2.5.2' + # keep this in sync with the VSWhereVersion in DefaultVersions.props + $vswhereVersion = '3.1.7' } $vsWhereDir = Join-Path $ToolsDir "vswhere\$vswhereVersion" @@ -541,7 +553,8 @@ function LocateVisualStudio([object]$vsRequirements = $null){ if (!(Test-Path $vsWhereExe)) { Create-Directory $vsWhereDir - Write-Host 'Downloading vswhere' + Write-Host "Downloading vswhere $vswhereVersion" + $ProgressPreference = 'SilentlyContinue' # Don't display the console progress UI - it's a huge perf hit Retry({ Invoke-WebRequest "https://netcorenativeassets.blob.core.windows.net/resource-packages/external/windows/vswhere/$vswhereVersion/vswhere.exe" -UseBasicParsing -OutFile $vswhereExe }) @@ -611,14 +624,7 @@ function InitializeBuildTool() { } $dotnetPath = Join-Path $dotnetRoot (GetExecutableFileName 'dotnet') - # Use override if it exists - commonly set by source-build - if ($null -eq $env:_OverrideArcadeInitializeBuildToolFramework) { - $initializeBuildToolFramework="net9.0" - } else { - $initializeBuildToolFramework=$env:_OverrideArcadeInitializeBuildToolFramework - } - - $buildTool = @{ Path = $dotnetPath; Command = 'msbuild'; Tool = 'dotnet'; Framework = $initializeBuildToolFramework } + $buildTool = @{ Path = $dotnetPath; Command = 'msbuild'; Tool = 'dotnet'; Framework = 'net' } } elseif ($msbuildEngine -eq "vs") { try { $msbuildPath = InitializeVisualStudioMSBuild -install:$restore @@ -627,7 +633,7 @@ function InitializeBuildTool() { ExitWithExitCode 1 } - $buildTool = @{ Path = $msbuildPath; Command = ""; Tool = "vs"; Framework = "net472"; ExcludePrereleaseVS = $excludePrereleaseVS } + $buildTool = @{ Path = $msbuildPath; Command = ""; Tool = "vs"; Framework = "netframework"; ExcludePrereleaseVS = $excludePrereleaseVS } } else { Write-PipelineTelemetryError -Category 'InitializeToolset' -Message "Unexpected value of -msbuildEngine: '$msbuildEngine'." ExitWithExitCode 1 @@ -660,7 +666,6 @@ function GetNuGetPackageCachePath() { $env:NUGET_PACKAGES = Join-Path $env:UserProfile '.nuget\packages\' } else { $env:NUGET_PACKAGES = Join-Path $RepoRoot '.packages\' - $env:RESTORENOHTTPCACHE = $true } } @@ -782,26 +787,13 @@ function MSBuild() { $toolsetBuildProject = InitializeToolset $basePath = Split-Path -parent $toolsetBuildProject - $possiblePaths = @( - # new scripts need to work with old packages, so we need to look for the old names/versions - (Join-Path $basePath (Join-Path $buildTool.Framework 'Microsoft.DotNet.ArcadeLogging.dll')), - (Join-Path $basePath (Join-Path $buildTool.Framework 'Microsoft.DotNet.Arcade.Sdk.dll')), - (Join-Path $basePath (Join-Path net7.0 'Microsoft.DotNet.ArcadeLogging.dll')), - (Join-Path $basePath (Join-Path net7.0 'Microsoft.DotNet.Arcade.Sdk.dll')), - (Join-Path $basePath (Join-Path net8.0 'Microsoft.DotNet.ArcadeLogging.dll')), - (Join-Path $basePath (Join-Path net8.0 'Microsoft.DotNet.Arcade.Sdk.dll')) - ) - $selectedPath = $null - foreach ($path in $possiblePaths) { - if (Test-Path $path -PathType Leaf) { - $selectedPath = $path - break - } - } + $selectedPath = Join-Path $basePath (Join-Path $buildTool.Framework 'Microsoft.DotNet.ArcadeLogging.dll') + if (-not $selectedPath) { - Write-PipelineTelemetryError -Category 'Build' -Message 'Unable to find arcade sdk logger assembly.' + Write-PipelineTelemetryError -Category 'Build' -Message "Unable to find arcade sdk logger assembly: $selectedPath" ExitWithExitCode 1 } + $args += "/logger:$selectedPath" } @@ -832,6 +824,11 @@ function MSBuild-Core() { $cmdArgs = "$($buildTool.Command) /m /nologo /clp:Summary /v:$verbosity /nr:$nodeReuse /p:ContinuousIntegrationBuild=$ci" + # Add -mt flag for MSBuild multithreaded mode if enabled via environment variable + if ($env:MSBUILD_MT_ENABLED -eq "1") { + $cmdArgs += ' -mt' + } + if ($warnAsError) { $cmdArgs += ' /warnaserror /p:TreatWarningsAsErrors=true' } @@ -864,8 +861,8 @@ function MSBuild-Core() { } # When running on Azure Pipelines, override the returned exit code to avoid double logging. - # Skip this when the build is a child of the VMR orchestrator build. - if ($ci -and $env:SYSTEM_TEAMPROJECT -ne $null -and !$productBuild -and -not($properties -like "*DotNetBuildRepo=true*")) { + # Skip this when the build is a child of the VMR build. + if ($ci -and $env:SYSTEM_TEAMPROJECT -ne $null -and !$fromVMR) { Write-PipelineSetResult -Result "Failed" -Message "msbuild execution failed." # Exiting with an exit code causes the azure pipelines task to log yet another "noise" error # The above Write-PipelineSetResult will cause the task to be marked as failure without adding yet another error diff --git a/eng/common/tools.sh b/eng/common/tools.sh index 01b09b65796..1b296f646c2 100755 --- a/eng/common/tools.sh +++ b/eng/common/tools.sh @@ -5,6 +5,9 @@ # CI mode - set to true on CI server for PR validation build or official build. ci=${ci:-false} +# Build mode +source_build=${source_build:-false} + # Set to true to use the pipelines logger which will enable Azure logging output. # https://github.com/Microsoft/azure-pipelines-tasks/blob/master/docs/authoring/commands.md # This flag is meant as a temporary opt-opt for the feature while validate it across @@ -58,7 +61,8 @@ use_installed_dotnet_cli=${use_installed_dotnet_cli:-true} dotnetInstallScriptVersion=${dotnetInstallScriptVersion:-'v1'} # True to use global NuGet cache instead of restoring packages to repository-local directory. -if [[ "$ci" == true ]]; then +# Keep in sync with NuGetPackageroot in Arcade SDK's RepositoryLayout.props. +if [[ "$ci" == true || "$source_build" == true ]]; then use_global_nuget_cache=${use_global_nuget_cache:-false} else use_global_nuget_cache=${use_global_nuget_cache:-true} @@ -68,8 +72,8 @@ fi runtime_source_feed=${runtime_source_feed:-''} runtime_source_feed_key=${runtime_source_feed_key:-''} -# True if the build is a product build -product_build=${product_build:-false} +# True when the build is running within the VMR. +from_vmr=${from_vmr:-false} # Resolve any symlinks in the given path. function ResolvePath { @@ -296,8 +300,29 @@ function GetDotNetInstallScript { local root=$1 local install_script="$root/dotnet-install.sh" local install_script_url="https://builds.dotnet.microsoft.com/dotnet/scripts/$dotnetInstallScriptVersion/dotnet-install.sh" + local timestamp_file="$root/.dotnet-install.timestamp" + local should_download=false if [[ ! -a "$install_script" ]]; then + should_download=true + elif [[ -f "$timestamp_file" ]]; then + # Check if the script is older than 30 days using timestamp file + local download_time=$(cat "$timestamp_file" 2>/dev/null || echo "0") + local current_time=$(date +%s) + local age_seconds=$((current_time - download_time)) + + # 30 days = 30 * 24 * 60 * 60 = 2592000 seconds + if [[ $age_seconds -gt 2592000 ]]; then + echo "Existing install script is too old, re-downloading..." + should_download=true + fi + else + # No timestamp file exists, assume script is old and re-download + echo "No timestamp found for existing install script, re-downloading..." + should_download=true + fi + + if [[ "$should_download" == true ]]; then mkdir -p "$root" echo "Downloading '$install_script_url'" @@ -324,6 +349,9 @@ function GetDotNetInstallScript { ExitWithExitCode $exit_code } fi + + # Create timestamp file to track download time in seconds from epoch + date +%s > "$timestamp_file" fi # return value _GetDotNetInstallScript="$install_script" @@ -339,22 +367,14 @@ function InitializeBuildTool { # return values _InitializeBuildTool="$_InitializeDotNetCli/dotnet" _InitializeBuildToolCommand="msbuild" - # use override if it exists - commonly set by source-build - if [[ "${_OverrideArcadeInitializeBuildToolFramework:-x}" == "x" ]]; then - _InitializeBuildToolFramework="net9.0" - else - _InitializeBuildToolFramework="${_OverrideArcadeInitializeBuildToolFramework}" - fi } -# Set RestoreNoHttpCache as a workaround for https://github.com/NuGet/Home/issues/3116 function GetNuGetPackageCachePath { if [[ -z ${NUGET_PACKAGES:-} ]]; then if [[ "$use_global_nuget_cache" == true ]]; then export NUGET_PACKAGES="$HOME/.nuget/packages/" else export NUGET_PACKAGES="$repo_root/.packages/" - export RESTORENOHTTPCACHE=true fi fi @@ -451,25 +471,13 @@ function MSBuild { fi local toolset_dir="${_InitializeToolset%/*}" - # new scripts need to work with old packages, so we need to look for the old names/versions - local selectedPath= - local possiblePaths=() - possiblePaths+=( "$toolset_dir/$_InitializeBuildToolFramework/Microsoft.DotNet.ArcadeLogging.dll" ) - possiblePaths+=( "$toolset_dir/$_InitializeBuildToolFramework/Microsoft.DotNet.Arcade.Sdk.dll" ) - possiblePaths+=( "$toolset_dir/net7.0/Microsoft.DotNet.ArcadeLogging.dll" ) - possiblePaths+=( "$toolset_dir/net7.0/Microsoft.DotNet.Arcade.Sdk.dll" ) - possiblePaths+=( "$toolset_dir/net8.0/Microsoft.DotNet.ArcadeLogging.dll" ) - possiblePaths+=( "$toolset_dir/net8.0/Microsoft.DotNet.Arcade.Sdk.dll" ) - for path in "${possiblePaths[@]}"; do - if [[ -f $path ]]; then - selectedPath=$path - break - fi - done + local selectedPath="$toolset_dir/net/Microsoft.DotNet.ArcadeLogging.dll" + if [[ -z "$selectedPath" ]]; then - Write-PipelineTelemetryError -category 'Build' "Unable to find arcade sdk logger assembly." + Write-PipelineTelemetryError -category 'Build' "Unable to find arcade sdk logger assembly: $selectedPath" ExitWithExitCode 1 fi + args+=( "-logger:$selectedPath" ) fi @@ -506,8 +514,8 @@ function MSBuild-Core { echo "Build failed with exit code $exit_code. Check errors above." # When running on Azure Pipelines, override the returned exit code to avoid double logging. - # Skip this when the build is a child of the VMR orchestrator build. - if [[ "$ci" == true && -n ${SYSTEM_TEAMPROJECT:-} && "$product_build" != true && "$properties" != *"DotNetBuildRepo=true"* ]]; then + # Skip this when the build is a child of the VMR build. + if [[ "$ci" == true && -n ${SYSTEM_TEAMPROJECT:-} && "$from_vmr" != true ]]; then Write-PipelineSetResult -result "Failed" -message "msbuild execution failed." # Exiting with an exit code causes the azure pipelines task to log yet another "noise" error # The above Write-PipelineSetResult will cause the task to be marked as failure without adding yet another error @@ -518,7 +526,13 @@ function MSBuild-Core { } } - RunBuildTool "$_InitializeBuildToolCommand" /m /nologo /clp:Summary /v:$verbosity /nr:$node_reuse $warnaserror_switch /p:TreatWarningsAsErrors=$warn_as_error /p:ContinuousIntegrationBuild=$ci "$@" + # Add -mt flag for MSBuild multithreaded mode if enabled via environment variable + local mt_switch="" + if [[ "${MSBUILD_MT_ENABLED:-}" == "1" ]]; then + mt_switch="-mt" + fi + + RunBuildTool "$_InitializeBuildToolCommand" /m /nologo /clp:Summary /v:$verbosity /nr:$node_reuse $warnaserror_switch $mt_switch /p:TreatWarningsAsErrors=$warn_as_error /p:ContinuousIntegrationBuild=$ci "$@" } function GetDarc { @@ -530,6 +544,13 @@ function GetDarc { fi "$eng_root/common/darc-init.sh" --toolpath "$darc_path" $version + darc_tool="$darc_path/darc" +} + +# Returns a full path to an Arcade SDK task project file. +function GetSdkTaskProject { + taskName=$1 + echo "$(dirname $_InitializeToolset)/SdkTasks/$taskName.proj" } ResolvePath "${BASH_SOURCE[0]}" diff --git a/eng/common/vmr-sync.ps1 b/eng/common/vmr-sync.ps1 new file mode 100644 index 00000000000..b37992d91cf --- /dev/null +++ b/eng/common/vmr-sync.ps1 @@ -0,0 +1,164 @@ +<# +.SYNOPSIS + +This script is used for synchronizing the current repository into a local VMR. +It pulls the current repository's code into the specified VMR directory for local testing or +Source-Build validation. + +.DESCRIPTION + +The tooling used for synchronization will clone the VMR repository into a temporary folder if +it does not already exist. These clones can be reused in future synchronizations, so it is +recommended to dedicate a folder for this to speed up re-runs. + +.EXAMPLE + Synchronize current repository into a local VMR: + ./vmr-sync.ps1 -vmrDir "$HOME/repos/dotnet" -tmpDir "$HOME/repos/tmp" + +.PARAMETER tmpDir +Required. Path to the temporary folder where repositories will be cloned + +.PARAMETER vmrBranch +Optional. Branch of the 'dotnet/dotnet' repo to synchronize. The VMR will be checked out to this branch + +.PARAMETER azdevPat +Optional. Azure DevOps PAT to use for cloning private repositories. + +.PARAMETER vmrDir +Optional. Path to the dotnet/dotnet repository. When null, gets cloned to the temporary folder + +.PARAMETER debugOutput +Optional. Enables debug logging in the darc vmr command. + +.PARAMETER ci +Optional. Denotes that the script is running in a CI environment. +#> +param ( + [Parameter(Mandatory=$true, HelpMessage="Path to the temporary folder where repositories will be cloned")] + [string][Alias('t', 'tmp')]$tmpDir, + [string][Alias('b', 'branch')]$vmrBranch, + [string]$remote, + [string]$azdevPat, + [string][Alias('v', 'vmr')]$vmrDir, + [switch]$ci, + [switch]$debugOutput +) + +function Fail { + Write-Host "> $($args[0])" -ForegroundColor 'Red' +} + +function Highlight { + Write-Host "> $($args[0])" -ForegroundColor 'Cyan' +} + +$verbosity = 'verbose' +if ($debugOutput) { + $verbosity = 'debug' +} +# Validation + +if (-not $tmpDir) { + Fail "Missing -tmpDir argument. Please specify the path to the temporary folder where the repositories will be cloned" + exit 1 +} + +# Sanitize the input + +if (-not $vmrDir) { + $vmrDir = Join-Path $tmpDir 'dotnet' +} + +if (-not (Test-Path -Path $tmpDir -PathType Container)) { + New-Item -ItemType Directory -Path $tmpDir | Out-Null +} + +# Prepare the VMR + +if (-not (Test-Path -Path $vmrDir -PathType Container)) { + Highlight "Cloning 'dotnet/dotnet' into $vmrDir.." + git clone https://github.com/dotnet/dotnet $vmrDir + + if ($vmrBranch) { + git -C $vmrDir switch -c $vmrBranch + } +} +else { + if ((git -C $vmrDir diff --quiet) -eq $false) { + Fail "There are changes in the working tree of $vmrDir. Please commit or stash your changes" + exit 1 + } + + if ($vmrBranch) { + Highlight "Preparing $vmrDir" + git -C $vmrDir checkout $vmrBranch + git -C $vmrDir pull + } +} + +Set-StrictMode -Version Latest + +# Prepare darc + +Highlight 'Installing .NET, preparing the tooling..' +. .\eng\common\tools.ps1 +$dotnetRoot = InitializeDotNetCli -install:$true +$env:DOTNET_ROOT = $dotnetRoot +$darc = Get-Darc + +Highlight "Starting the synchronization of VMR.." + +# Synchronize the VMR +$versionDetailsPath = Resolve-Path (Join-Path $PSScriptRoot '..\Version.Details.xml') | Select-Object -ExpandProperty Path +[xml]$versionDetails = Get-Content -Path $versionDetailsPath +$repoName = $versionDetails.SelectSingleNode('//Source').Mapping +if (-not $repoName) { + Fail "Failed to resolve repo mapping from $versionDetailsPath" + exit 1 +} + +$darcArgs = ( + "vmr", "forwardflow", + "--tmp", $tmpDir, + "--$verbosity", + $vmrDir +) + +if ($ci) { + $darcArgs += ("--ci") +} + +if ($azdevPat) { + $darcArgs += ("--azdev-pat", $azdevPat) +} + +& "$darc" $darcArgs + +if ($LASTEXITCODE -eq 0) { + Highlight "Synchronization succeeded" +} +else { + Highlight "Failed to flow code into the local VMR. Falling back to resetting the VMR to match repo contents..." + git -C $vmrDir reset --hard + + $resetArgs = ( + "vmr", "reset", + "${repoName}:HEAD", + "--vmr", $vmrDir, + "--tmp", $tmpDir, + "--additional-remotes", "${repoName}:${repoRoot}" + ) + + & "$darc" $resetArgs + + if ($LASTEXITCODE -eq 0) { + Highlight "Successfully reset the VMR using 'darc vmr reset'" + } + else { + Fail "Synchronization of repo to VMR failed!" + Fail "'$vmrDir' is left in its last state (re-run of this script will reset it)." + Fail "Please inspect the logs which contain path to the failing patch file (use -debugOutput to get all the details)." + Fail "Once you make changes to the conflicting VMR patch, commit it locally and re-run this script." + exit 1 + } +} diff --git a/eng/common/vmr-sync.sh b/eng/common/vmr-sync.sh new file mode 100644 index 00000000000..198caec59bd --- /dev/null +++ b/eng/common/vmr-sync.sh @@ -0,0 +1,227 @@ +#!/bin/bash + +### This script is used for synchronizing the current repository into a local VMR. +### It pulls the current repository's code into the specified VMR directory for local testing or +### Source-Build validation. +### +### The tooling used for synchronization will clone the VMR repository into a temporary folder if +### it does not already exist. These clones can be reused in future synchronizations, so it is +### recommended to dedicate a folder for this to speed up re-runs. +### +### USAGE: +### Synchronize current repository into a local VMR: +### ./vmr-sync.sh --tmp "$HOME/repos/tmp" "$HOME/repos/dotnet" +### +### Options: +### -t, --tmp, --tmp-dir PATH +### Required. Path to the temporary folder where repositories will be cloned +### +### -b, --branch, --vmr-branch BRANCH_NAME +### Optional. Branch of the 'dotnet/dotnet' repo to synchronize. The VMR will be checked out to this branch +### +### --debug +### Optional. Turns on the most verbose logging for the VMR tooling +### +### --remote name:URI +### Optional. Additional remote to use during the synchronization +### This can be used to synchronize to a commit from a fork of the repository +### Example: 'runtime:https://github.com/yourfork/runtime' +### +### --azdev-pat +### Optional. Azure DevOps PAT to use for cloning private repositories. +### +### -v, --vmr, --vmr-dir PATH +### Optional. Path to the dotnet/dotnet repository. When null, gets cloned to the temporary folder + +source="${BASH_SOURCE[0]}" + +# resolve $source until the file is no longer a symlink +while [[ -h "$source" ]]; do + scriptroot="$( cd -P "$( dirname "$source" )" && pwd )" + source="$(readlink "$source")" + # if $source was a relative symlink, we need to resolve it relative to the path where the + # symlink file was located + [[ $source != /* ]] && source="$scriptroot/$source" +done +scriptroot="$( cd -P "$( dirname "$source" )" && pwd )" + +function print_help () { + sed -n '/^### /,/^$/p' "$source" | cut -b 5- +} + +COLOR_RED=$(tput setaf 1 2>/dev/null || true) +COLOR_CYAN=$(tput setaf 6 2>/dev/null || true) +COLOR_CLEAR=$(tput sgr0 2>/dev/null || true) +COLOR_RESET=uniquesearchablestring +FAILURE_PREFIX='> ' + +function fail () { + echo "${COLOR_RED}$FAILURE_PREFIX${1//${COLOR_RESET}/${COLOR_RED}}${COLOR_CLEAR}" >&2 +} + +function highlight () { + echo "${COLOR_CYAN}$FAILURE_PREFIX${1//${COLOR_RESET}/${COLOR_CYAN}}${COLOR_CLEAR}" +} + +tmp_dir='' +vmr_dir='' +vmr_branch='' +additional_remotes='' +verbosity=verbose +azdev_pat='' +ci=false + +while [[ $# -gt 0 ]]; do + opt="$(echo "$1" | tr "[:upper:]" "[:lower:]")" + case "$opt" in + -t|--tmp|--tmp-dir) + tmp_dir=$2 + shift + ;; + -v|--vmr|--vmr-dir) + vmr_dir=$2 + shift + ;; + -b|--branch|--vmr-branch) + vmr_branch=$2 + shift + ;; + --remote) + additional_remotes="$additional_remotes $2" + shift + ;; + --azdev-pat) + azdev_pat=$2 + shift + ;; + --ci) + ci=true + ;; + -d|--debug) + verbosity=debug + ;; + -h|--help) + print_help + exit 0 + ;; + *) + fail "Invalid argument: $1" + print_help + exit 1 + ;; + esac + + shift +done + +# Validation + +if [[ -z "$tmp_dir" ]]; then + fail "Missing --tmp-dir argument. Please specify the path to the temporary folder where the repositories will be cloned" + exit 1 +fi + +# Sanitize the input + +if [[ -z "$vmr_dir" ]]; then + vmr_dir="$tmp_dir/dotnet" +fi + +if [[ ! -d "$tmp_dir" ]]; then + mkdir -p "$tmp_dir" +fi + +if [[ "$verbosity" == "debug" ]]; then + set -x +fi + +# Prepare the VMR + +if [[ ! -d "$vmr_dir" ]]; then + highlight "Cloning 'dotnet/dotnet' into $vmr_dir.." + git clone https://github.com/dotnet/dotnet "$vmr_dir" + + if [[ -n "$vmr_branch" ]]; then + git -C "$vmr_dir" switch -c "$vmr_branch" + fi +else + if ! git -C "$vmr_dir" diff --quiet; then + fail "There are changes in the working tree of $vmr_dir. Please commit or stash your changes" + exit 1 + fi + + if [[ -n "$vmr_branch" ]]; then + highlight "Preparing $vmr_dir" + git -C "$vmr_dir" checkout "$vmr_branch" + git -C "$vmr_dir" pull + fi +fi + +set -e + +# Prepare darc + +highlight 'Installing .NET, preparing the tooling..' +source "./eng/common/tools.sh" +InitializeDotNetCli true +GetDarc +dotnetDir=$( cd ./.dotnet/; pwd -P ) +dotnet=$dotnetDir/dotnet + +highlight "Starting the synchronization of VMR.." +set +e + +if [[ -n "$additional_remotes" ]]; then + additional_remotes="--additional-remotes $additional_remotes" +fi + +if [[ -n "$azdev_pat" ]]; then + azdev_pat="--azdev-pat $azdev_pat" +fi + +ci_arg='' +if [[ "$ci" == "true" ]]; then + ci_arg="--ci" +fi + +# Synchronize the VMR + +version_details_path=$(cd "$scriptroot/.."; pwd -P)/Version.Details.xml +repo_name=$(grep -m 1 ' - + diff --git a/global.json b/global.json index 8decbcb016e..11caa1491ad 100644 --- a/global.json +++ b/global.json @@ -1,9 +1,9 @@ { "sdk": { - "version": "10.0.103" + "version": "10.0.105" }, "tools": { - "dotnet": "10.0.103", + "dotnet": "10.0.105", "runtimes": { "dotnet": [ "8.0.0", @@ -20,7 +20,7 @@ "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.0", "Microsoft.Build.Traversal": "3.2.0", - "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.26123.3", - "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.26123.3" + "Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.26168.1", + "Microsoft.DotNet.Helix.Sdk": "10.0.0-beta.26168.1" } } diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingServiceCollectionExtensions.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingServiceCollectionExtensions.cs index c98055ff8b1..e84702294ce 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingServiceCollectionExtensions.cs @@ -18,7 +18,6 @@ namespace Microsoft.Extensions.DependencyInjection; /// /// Extension methods to register the HTTP logging feature within the service. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.HttpLogging, UrlFormat = DiagnosticIds.UrlFormat)] public static class HttpLoggingServiceCollectionExtensions { /// @@ -31,6 +30,7 @@ public static class HttpLoggingServiceCollectionExtensions /// Configures the redaction options. /// The value of . /// is . + [Experimental(diagnosticId: DiagnosticIds.Experiments.HttpLogging, UrlFormat = DiagnosticIds.UrlFormat)] public static IServiceCollection AddHttpLoggingRedaction(this IServiceCollection services, Action? configure = null) { _ = Throw.IfNull(services); @@ -56,6 +56,7 @@ public static IServiceCollection AddHttpLoggingRedaction(this IServiceCollection /// The service collection. /// The configuration section with the redaction settings. /// The value of . + [Experimental(diagnosticId: DiagnosticIds.Experiments.HttpLogging, UrlFormat = DiagnosticIds.UrlFormat)] public static IServiceCollection AddHttpLoggingRedaction(this IServiceCollection services, IConfigurationSection section) { _ = Throw.IfNull(section); diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/IHttpLogEnricher.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/IHttpLogEnricher.cs index 5c221368cd5..ca87297c498 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/IHttpLogEnricher.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/IHttpLogEnricher.cs @@ -3,17 +3,14 @@ #if NET8_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Diagnostics.Enrichment; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.AspNetCore.Diagnostics.Logging; /// /// Interface for implementing log enrichers for incoming HTTP requests. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.HttpLogging, UrlFormat = DiagnosticIds.UrlFormat)] public interface IHttpLogEnricher { /// diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs index e18822e7ad5..aa8c0975e94 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs @@ -4,9 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Compliance.Classification; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.AspNetCore.Diagnostics.Logging; @@ -22,6 +20,5 @@ public class RequestHeadersLogEnricherOptions /// Default value is an empty dictionary. /// [Required] - [Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public IDictionary HeadersDataClasses { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); } diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.json b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.json index f445bb581f5..2e7745a3610 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.json +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.json @@ -1,13 +1,13 @@ { - "Name": "Microsoft.AspNetCore.Diagnostics.Middleware, Version=9.7.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.AspNetCore.Diagnostics.Middleware, Version=10.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "static class Microsoft.Extensions.DependencyInjection.HttpLoggingServiceCollectionExtensions", - "Stage": "Experimental", + "Stage": "Stable", "Methods": [ { "Member": "static Microsoft.Extensions.DependencyInjection.IServiceCollection Microsoft.Extensions.DependencyInjection.HttpLoggingServiceCollectionExtensions.AddHttpLogEnricher(this Microsoft.Extensions.DependencyInjection.IServiceCollection services);", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "static Microsoft.Extensions.DependencyInjection.IServiceCollection Microsoft.Extensions.DependencyInjection.HttpLoggingServiceCollectionExtensions.AddHttpLoggingRedaction(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action? configure = null);", @@ -78,11 +78,11 @@ }, { "Type": "interface Microsoft.AspNetCore.Diagnostics.Logging.IHttpLogEnricher", - "Stage": "Experimental", + "Stage": "Stable", "Methods": [ { "Member": "void Microsoft.AspNetCore.Diagnostics.Logging.IHttpLogEnricher.Enrich(Microsoft.Extensions.Diagnostics.Enrichment.IEnrichmentTagCollector collector, Microsoft.AspNetCore.Http.HttpContext httpContext);", - "Stage": "Experimental" + "Stage": "Stable" } ] }, @@ -189,7 +189,7 @@ "Stage": "Stable" }, { - "Member": "System.Collections.Generic.IList Microsoft.AspNetCore.Diagnostics.Buffering.PerRequestLogBufferingOptions.Rules { get; set; }", + "Member": "System.Collections.Generic.IList Microsoft.AspNetCore.Diagnostics.Buffering.PerRequestLogBufferingOptions.Rules { get; set; }", "Stage": "Stable" } ] @@ -251,7 +251,7 @@ "Properties": [ { "Member": "System.Collections.Generic.IDictionary Microsoft.AspNetCore.Diagnostics.Logging.RequestHeadersLogEnricherOptions.HeadersDataClasses { get; set; }", - "Stage": "Experimental" + "Stage": "Stable" } ] }, diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md deleted file mode 100644 index 4bf54ef2c1b..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ /dev/null @@ -1,245 +0,0 @@ -# Microsoft.Extensions.AI.Abstractions Release History - -## NOT YET RELEASED - -- `AddAIContentType` now automatically registers the content type against every base in the inheritance chain up to `AIContent`. -- Added `IHostedFileClient` interface and related types for interacting with files hosted by the service. -- Added `WebSearchToolCallContent` and `WebSearchToolResultContent` for representing web search tool calls and results. -- Added `ToolCallContent` and `ToolResultContent` base classes. -- Updated the design of the MCP and approvals-related types and marked them as stable. -- Updated AI function parameter JSON schema generation to honor `[Required]` attributes. -- Updated `AIFunctionFactory` to work better with `DynamicMethod`-based functions. -- Removed the experimental `IToolReductionStrategy` type. - -## 10.3.0 - -- Added `ReasoningOptions` to `ChatOptions` for configuring reasoning effort and output. -- Unsealed `FunctionCallContent` and `FunctionResultContent`. -- Added `InformationalOnly` property to `FunctionCallContent` to indicate whether the content is informing the consumer about a call that's being made elsewhere or that is a request for the call to be performed. -- Added `LoadFromAsync` and `SaveToAsync` helper methods to `DataContent` for file I/O operations. -- Removed `[Experimental]` attribute from `IChatReducer`. -- Fixed JSON schema generation for nullable reference type annotations on parameters in AIFunctions. -- Fixed `DataUriParser` to default to `text/plain;charset=US-ASCII` per RFC 2397. -- Fixed serialization handling of `ImageGenerationToolCallContent` and `ImageGenerationToolResultContent`. - -## 10.2.0 - -- Updated `ToChatResponse{Async}`'s handling of `AdditionalProperties`, such that `ChatResponseUpdate.AdditionalProperties` is merged into `ChatMessage.AdditionalProperties` for updates that have a non-`null` `MessageId`. -- Updated `ToChatResponse{Async}` to use the first appropriate `ChatResponseUpdate`'s `CreatedAt` timestamp rather than the last. -- Added a `Reason` property to `FunctionApprovalResponseContent` in support of custom rejection messages. -- Added support for custom headers to `HostedMcpServerTool`. - -## 10.1.1 - -- Added `InputCachedTokenCount` and `ReasoningTokenCount` to `UsageDetails`. -- Added constructors to `HostedCodeInterpreterTool`, `HostedFileSearchTool`, `HostedImageGeneratorTool`, `HostedMcpServerTool`, - and `HostedWebSearchTool` that accept a dictionary for `AdditionalProperties`. - -## 10.1.0 - -- Fixed package references for net10.0 asset. -- Added `AIJsonSchemaCreateOptions.ParameterDescriptions`. - -## 10.0.1 - -- Updated return type of [Experimental] `ContinuationToken` properties. -- Fixed ValidateSchemaDocument's handling of valid Boolean schemas. - -## 10.0.0 - -- Added experimental `HostedImageGenerationTool`. -- Updated .NET dependencies to 10.0.0 versions. - -## 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. -- Added `CodeInterpreterToolCallContent`/`CodeInterpreterToolResultContent` for representing code interpreter tool calls and results. -- Added `Name`, `MediaType`, and `HasTopLevelMediaType` to `HostedFileContent`. -- Fixed the serialization/deserialization of variables typed as `UserInputRequestContent`/`UserInputResponseContent`. - -## 9.10.1 - -- Updated `HostedMcpServerTool` to allow for non-`Uri` server addresses, in order to enable built-in names. -- Updated `HostedMcpServerTool` to replace the header collection with an `AuthorizationToken` property. -- Fixed `ToChatResponse{Async}` to not discard `TextReasoningContent.ProtectedData` when coalescing messages. -- Fixed `AIFunctionFactory.Create` to special-case return types of `AIContent` and `IEnumerable` to not automatically JSON serialize them. - -## 9.10.0 - -- Added protected copy constructors to options types (e.g. `ChatOptions`). -- Added `[Experimental]` support for background responses, such that non-streaming responses are allowed to be pollable and responses / response updates can be tagged with continuation tokens to support later resumption. -- Updated `AIFunctionFactory.Create` to produce better default names for lambdas and local functions. -- Fixed `AIJsonUtilities.DefaultOptions` to handle the built-in `[Experimental]` `AIContent` types, like `FunctionApprovalRequestContent`. -- Fixed `ToChatResponse{Async}` to factor `ChatResponseUpdate.AuthorName` into message boundary detection. -- Fixed `ToChatResponse{Async}` to not overwrite `ChatMessage/ChatResponse.CreatedAt` with older timestamps during coalescing. -- Fixed `EmbeddingGeneratorOptions`/`SpeechToTextOptions` `Clone` methods to correctly copy all properties. - -## 9.9.1 - -- Added new `ChatResponseFormat.ForJsonSchema` overloads that export a JSON schema from a .NET type. -- Added new `AITool.GetService` virtual method. -- Updated `TextReasoningContent` to include `ProtectedData` for representing encrypted/redacted content. -- Fixed `MinLength`/`MaxLength`/`Length` attribute mapping in nullable string properties during schema export. - -## 9.9.0 - -- Added non-invocable `AIFunctionDeclaration` (base class for `AIFunction`), `AIFunctionFactory.CreateDeclaration`, and `AIFunction.AsDeclarationOnly`. -- Added `[Experimental]` support for user approval of function invocations via `ApprovalRequiredAIFunction`, `FunctionApprovalRequestContent`, and friends. -- Added `[Experimental]` support for MCP server-hosted tools via `HostedMcpServerTool`, `HostedMcpServerToolApprovalMode`, and friends. -- Updated `AIContent` coalescing logic used by `ToChatResponse`/`ToChatResponseUpdate` to factor in `ChatMessage.Role`. -- Moved `IChatReducer` into `Microsoft.Extensions.AI.Abstractions` from `Microsoft.Extensions.AI`. - -## 9.8.0 - -- Added `AIAnnotation` and related types to represent citations and other annotations in chat messages. -- Added `ChatMessage.CreatedAt` so that chat messages can carry their timestamp. -- Added a `[Description(...)]` attribute to `DataContent.Uri` to clarify its purpose when used in schemas. -- Added `DataContent.Name` property to associate a name with the binary data, like a filename. -- Added `HostedFileContent` for representing files hosted by the service. -- Added `HostedVectorStoreContent` for representing vector stores hosted by the service. -- Added `HostedFileSearchTool` to represent server-side file search tools. -- Added `HostedCodeInterpreterTool.Inputs` to supply context about what state is available to the code interpreter tool. -- Added [Experimental] `IImageGenerator` and supporting types. -- Improved handling of function parameter data annotation attributes in `AIJsonUtilities.CreateJsonSchema`. -- Fixed schema generation to include an items keyword for arrays of objects in `AIJsonUtilities.CreateJsonSchema`. - -## 9.7.1 - -- Fixed schema generation for nullable function parameters in `AIJsonUtilities.CreateJsonSchema`. -- Added a flag for `AIFunctionFactory` to control whether return schemas are generated. -- Added `DelegatingAIFunction` to simplify creating `AIFunction`s that call other `AIFunction`s. -- Updated `AIFunctionFactory` to tolerate JSON string function parameters. -- Fixed schema generation for nullable value type parameters. - -## 9.7.0 - -- Added `ChatOptions.Instructions` property for configuring system instructions separate from chat messages. -- Added `Usage` property to `SpeechToTextResponse` to provide details about the token usage. -- Augmented `AIJsonUtilities.CreateJsonSchema` with support for data annotations. - -## 9.6.0 - -- Added `AIFunction.ReturnJsonSchema` to represent the JSON schema of the return value of a function. -- Removed title and description keywords from root-level schemas in `AIFunctionFactory`. - -## 9.5.0 - -- Moved `AIFunctionFactory` down from `Microsoft.Extensions.AI` to `Microsoft.Extensions.AI.Abstractions`. -- Added `BinaryEmbedding` type for representing bit embeddings. -- Added `TextReasoningContent` to represent reasoning content in chat messages. -- Added `ChatOptions.AllowMultipleToolCalls` for configuring parallel tool calling. -- Added a public constructor to the base `AIContent`. -- Added a missing `[DebuggerDisplay]` attribute on `AIFunctionArguments`. -- Added `ChatOptions.RawRepresentationFactory` to facilitate passing raw options to the underlying service. -- Added an `AIJsonSchemaTransformOptions` property inside `AIJsonSchemaCreateOptions`. -- Added `DataContent.Base64Data` property for easier and more efficient handling of base64-encoded data. -- Added JSON schema transformation functionality to `AIJsonUtilities`. -- Fixed `AIJsonUtilities.CreateJsonSchema` to handle `JsonSerializerOptions` that do not have a `TypeInfoResolver` configured. -- Fixed `AIFunctionFactory` handling of default struct arguments. -- Fixed schema generation to ensure the type keyword is included when generating schemas for nullable enums. -- Renamed the `GenerateXx` extension methods on `IEmbeddingGenerator<>`. -- Renamed `ChatThreadId` to `ConversationId` across the libraries. -- Replaced `Type targetType` parameter in `AIFunctionFactory.Create` with a delegate. -- Remove `[Obsolete]` members from previews. - -## 9.4.4-preview.1.25259.16 - -- Added `AIJsonUtilities.TransformSchema` and supporting types. -- Added `BinaryEmbedding` for bit embeddings. -- Added `ChatOptions.RawRepresentationFactory` to make it easier to pass options to the underlying service. -- Added `Base64Data` property to `DataContent`. -- Moved `AIFunctionFactory` to `Microsoft.Extensions.AI.Abstractions`. -- Fixed `AIFunctionFactory` handling of default struct arguments. - -## 9.4.3-preview.1.25230.7 - -- Renamed `ChatThreadId` to `ConversationId` on `ChatResponse`, `ChatResponseUpdate`, and `ChatOptions`. -- Renamed `EmbeddingGeneratorExtensions` method `GenerateEmbeddingAsync` to `GenerateAsync` and `GenerateEmbeddingVectorAsync` to `GenerateVectorAsync`. -- Made `AIContent`'s constructor `public` instead of `protected`. -- Fixed `AIJsonUtilities.CreateJsonSchema` to tolerate `JsonSerializerOptions` instances that don't have a `TypeInfoResolver` already configured. - -## 9.4.0-preview.1.25207.5 - -- Added `ErrorContent` and `TextReasoningContent`. -- Added `MessageId` to `ChatMessage` and `ChatResponseUpdate`. -- Added `AIFunctionArguments`, changing `AIFunction.InvokeAsync` to accept one and to return a `ValueTask`. -- Updated `AIJsonUtilities`'s schema generation to not use `default` when `RequireAllProperties` is set to `true`. -- Added [Experimental] `ISpeechToTextClient` and supporting types. -- Fixed several issues related to Native AOT support. - -## 9.3.0-preview.1.25161.3 - -- Changed `IChatClient.GetResponseAsync` and `IChatClient.GetStreamingResponseAsync` to accept an `IEnumerable` rather than an `IList`. It is no longer mutated by implementations. -- Removed `ChatResponse.Choice` and `ChatResponseUpdate.ChoiceIndex`. -- Replaced `ChatResponse.Message` with `ChatResponse.Messages`. Responses now carry with them all messages generated as part of the operation, rather than all but the last being added to the history and the last returned. -- Added `GetRequiredService` extension method for `IChatClient`/`IEmbeddingGenerator`. -- Added non-generic `IEmbeddingGenerator` interface, which is inherited by `IEmbeddingGenerator`. The `GetService` method moves down to the non-generic interface, and the `GetService`/`GetRequiredService` extension methods are now in terms of the non-generic. -- `AIJsonUtilities.CreateFunctionJsonSchema` now special-cases `CancellationToken` to not include it in the schema. -- Improved the debugger displays for `ChatMessage` and the `AIContent` types. -- Added a static `AIJsonUtilities.HashDataToString` method. -- Split `DataContent`, which handled both in-memory data and URIs to remote data, into `DataContent` (for the former) and `UriContent` (for the latter). -- Renamed `DataContent.MediaTypeStartsWith` to `DataContent.HasTopLevelMediaType`, and changed semantics accordingly. - -## 9.3.0-preview.1.25114.11 - -- Renamed `IChatClient.Complete{Streaming}Async` to `IChatClient.Get{Streaming}ResponseAsync`. This is to avoid confusion with "Complete" being about stopping an operation, as well as to avoid tying the methods to a particular implementation detail of how responses are generated. Along with this, renamed `ChatCompletion` to `ChatResponse`, `StreamingChatCompletionUpdate` to `ChatResponseUpdate`, `CompletionId` to `ResponseId`, `ToStreamingChatCompletionUpdates` to `ToChatResponseUpdates`, and `ToChatCompletion{Async}` to `ToChatResponse{Async}`. -- Removed `IChatClient.Metadata` and `IEmbeddingGenerator.Metadata`. The `GetService` method may be used to retrieve `ChatClientMetadata` and `EmbeddingGeneratorMetadata`, respectively. -- Added overloads of `Get{Streaming}ResponseAsync` that accept a single `ChatMessage` (in addition to the other overloads that accept a `List` or a `string`). -- Added `ChatThreadId` properties to `ChatOptions`, `ChatResponse`, and `ChatResponseUpdate`. `IChatClient` can now be used in both stateful and stateless modes of operation, such as with agents that maintain server-side chat history. -- Made `ChatOptions.ToolMode` nullable and added a `None` option. -- Changed `UsageDetails`'s properties from `int?` to `long?`. -- Removed `DataContent.ContainsData`; `DataContent.Data.HasValue` may be used instead. -- Removed `ImageContent` and `AudioContent`; the base `DataContent` should now be used instead, with a new `DataContent.MediaTypeStartsWith` helper for routing based on media type. -- Removed setters on `FunctionCallContent` and `FunctionResultContent` properties where the value is supplied to the constructor. -- Removed `FunctionResultContent.Name`. -- Augmented the base `AITool` with `Name`, `Description`, and `AdditionalProperties` virtual properties. -- Added a `CodeInterpreterTool` for use with services that support server-side code execution. -- Changed `AIFunction`'s schema representation to be for the whole function rather than per parameter, and exposed corresponding methods on `AIJsonUtilities`, e.g. `CreateFunctionJsonSchema`. -- Removed `AIFunctionParameterMetadata` and `AIFunctionReturnParameterMetadata` classes and corresponding properties on `AIFunction` and `AIFunctionFactoryCreateOptions`, replacing them with a `MethodInfo?`. All relevant metadata, such as the JSON schema for the function, are moved to properties directly on `AIFunction`. -- Renamed `AIFunctionFactoryCreateOptions` to `AIFunctionFactoryOptions` and made all its properties nullable. -- Changed `AIJsonUtilities.DefaultOptions` to use relaxed JSON escaping. -- Made `IEmbeddingGenerator` contravariant on `TInput`. - -## 9.1.0-preview.1.25064.3 - -- Added `AdditionalPropertiesDictionary` and changed `UsageDetails.AdditionalProperties` to be named `AdditionalCounts` and to be of type `AdditionalPropertiesDictionary`. -- Updated `FunctionCallingChatClient` to sum all `UsageDetails` token counts from all intermediate messages. -- Fixed JSON schema generation for floating-point types. -- Added `AddAIContentType` for enabling custom `AIContent`-derived types to participate in polymorphic serialization. - -## 9.0.1-preview.1.24570.5 - -- Changed `IChatClient`/`IEmbeddingGenerator`.`GetService` to be non-generic. -- Added `ToChatCompletion` / `ToChatCompletionUpdate` extension methods for `IEnumerable` / `IAsyncEnumerable`, respectively. -- Added `ToStreamingChatCompletionUpdates` instance method to `ChatCompletion`. -- Added `IncludeTypeInEnumSchemas`, `DisallowAdditionalProperties`, `RequireAllProperties`, and `TransformSchemaNode` options to `AIJsonSchemaCreateOptions`. -- Fixed a Native AOT warning in `AIFunctionFactory.Create`. -- Fixed a bug in `AIJsonUtilities` in the handling of Boolean schemas. -- Improved the `ToString` override of `ChatMessage` and `StreamingChatCompletionUpdate` to include all `TextContent`, and of `ChatCompletion` to include all choices. -- Added `DebuggerDisplay` attributes to `DataContent` and `GeneratedEmbeddings`. -- Improved the documentation. - -## 9.0.0-preview.9.24556.5 - -- Added a strongly-typed `ChatOptions.Seed` property. -- Improved `AdditionalPropertiesDictionary` with a `TryAdd` method, a strongly-typed `Enumerator`, and debugger-related attributes for improved debuggability. -- Fixed `AIJsonUtilities` schema generation for Boolean schemas. - -## 9.0.0-preview.9.24525.1 - -- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. -- Annotated `FunctionCallContent.Exception` and `FunctionResultContent.Exception` as `[JsonIgnore]`, such that they're ignored when serializing instances with `JsonSerializer`. The corresponding constructors accepting an `Exception` were removed. -- Annotated `ChatCompletion.Message` as `[JsonIgnore]`, such that it's ignored when serializing instances with `JsonSerializer`. -- Added the `FunctionCallContent.CreateFromParsedArguments` method. -- Added the `AdditionalPropertiesDictionary.TryGetValue` method. -- Added the `StreamingChatCompletionUpdate.ModelId` property and removed the `AIContent.ModelId` property. -- Renamed the `GenerateAsync` extension method on `IEmbeddingGenerator<,>` to `GenerateEmbeddingsAsync` and updated it to return `Embedding` rather than `GeneratedEmbeddings`. -- Added `GenerateAndZipAsync` and `GenerateEmbeddingVectorAsync` extension methods for `IEmbeddingGenerator<,>`. -- Added the `EmbeddingGeneratorOptions.Dimensions` property. -- Added the `ChatOptions.TopK` property. -- Normalized `null` inputs in `TextContent` to be empty strings. - -## 9.0.0-preview.9.24507.7 - -- Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs index 4cbebdb3776..0ba4a0e1ccc 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs @@ -237,39 +237,37 @@ private static void CoalesceWebSearchToolCallContent(IList contents) for (int i = 0; i < contents.Count; i++) { - if (contents[i] is WebSearchToolCallContent webSearchCall && !string.IsNullOrEmpty(webSearchCall.CallId)) + if (contents[i] is WebSearchToolCallContent webSearchCall) { webSearchCallIndexById ??= new(StringComparer.Ordinal); - if (webSearchCallIndexById.TryGetValue(webSearchCall.CallId!, out int existingIndex)) + if (webSearchCallIndexById.TryGetValue(webSearchCall.CallId, out int existingIndex)) { - // Merge data from the new item into the existing one. + // Create a new merged content rather than mutating the original content objects. + // The same content objects may be shared across multiple ToChatResponse calls + // (e.g. FunctionInvokingChatClient and the caller both call ToChatResponse on + // the same streaming updates), and in-place mutation would corrupt subsequent calls. var existing = (WebSearchToolCallContent)contents[existingIndex]; - if (webSearchCall.Queries is { Count: > 0 }) + if (!ReferenceEquals(existing, webSearchCall)) { - if (existing.Queries is null) + contents[existingIndex] = new WebSearchToolCallContent(existing.CallId) { - existing.Queries = webSearchCall.Queries; - } - else - { - foreach (var query in webSearchCall.Queries) - { - existing.Queries.Add(query); - } - } + Queries = webSearchCall.Queries is not { Count: > 0 } ? existing.Queries : + existing.Queries is not { Count: > 0 } ? webSearchCall.Queries : + [.. existing.Queries, .. webSearchCall.Queries], + RawRepresentation = existing.RawRepresentation ?? webSearchCall.RawRepresentation, + AdditionalProperties = existing.AdditionalProperties ?? webSearchCall.AdditionalProperties, + Annotations = existing.Annotations ?? webSearchCall.Annotations, + }; } - existing.RawRepresentation ??= webSearchCall.RawRepresentation; - existing.AdditionalProperties ??= webSearchCall.AdditionalProperties; - contents[i] = null!; hasRemovals = true; } else { - webSearchCallIndexById[webSearchCall.CallId!] = i; + webSearchCallIndexById[webSearchCall.CallId] = i; } } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml deleted file mode 100644 index 106feff432e..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml +++ /dev/null @@ -1,808 +0,0 @@ - - - - CP0001 - T:Microsoft.Extensions.AI.FunctionApprovalRequestContent - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.FunctionApprovalResponseContent - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.McpServerToolApprovalRequestContent - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.McpServerToolApprovalResponseContent - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.UserInputRequestContent - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.UserInputResponseContent - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.FunctionApprovalRequestContent - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.FunctionApprovalResponseContent - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.McpServerToolApprovalRequestContent - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.McpServerToolApprovalResponseContent - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.UserInputRequestContent - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.UserInputResponseContent - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.FunctionApprovalRequestContent - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.FunctionApprovalResponseContent - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.McpServerToolApprovalRequestContent - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.McpServerToolApprovalResponseContent - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.UserInputRequestContent - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.UserInputResponseContent - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.FunctionApprovalRequestContent - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.FunctionApprovalResponseContent - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.McpServerToolApprovalRequestContent - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.McpServerToolApprovalResponseContent - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.UserInputRequestContent - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.UserInputResponseContent - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.FunctionApprovalRequestContent - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.FunctionApprovalResponseContent - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.McpServerToolApprovalRequestContent - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.McpServerToolApprovalResponseContent - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.UserInputRequestContent - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.UserInputResponseContent - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolCallContent.#ctor - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolCallContent.set_CallId(System.String) - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolResultContent.#ctor - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolResultContent.set_CallId(System.String) - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.get_AuthorizationToken - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.set_AuthorizationToken(System.String) - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.#ctor - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.get_ImageId - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.set_ImageId(System.String) - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.#ctor - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.get_ImageId - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.set_ImageId(System.String) - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolCallContent.get_Arguments - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolCallContent.get_ToolName - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolResultContent.get_Output - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolResultContent.set_Output(System.Collections.Generic.IList{Microsoft.Extensions.AI.AIContent}) - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolCallContent.#ctor - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolCallContent.set_CallId(System.String) - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolResultContent.#ctor - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolResultContent.set_CallId(System.String) - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.get_AuthorizationToken - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.set_AuthorizationToken(System.String) - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.#ctor - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.get_ImageId - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.set_ImageId(System.String) - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.#ctor - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.get_ImageId - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.set_ImageId(System.String) - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolCallContent.get_Arguments - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolCallContent.get_ToolName - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolResultContent.get_Output - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolResultContent.set_Output(System.Collections.Generic.IList{Microsoft.Extensions.AI.AIContent}) - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolCallContent.#ctor - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolCallContent.set_CallId(System.String) - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolResultContent.#ctor - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolResultContent.set_CallId(System.String) - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.get_AuthorizationToken - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.set_AuthorizationToken(System.String) - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.#ctor - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.get_ImageId - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.set_ImageId(System.String) - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.#ctor - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.get_ImageId - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.set_ImageId(System.String) - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolCallContent.get_Arguments - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolCallContent.get_ToolName - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolResultContent.get_Output - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolResultContent.set_Output(System.Collections.Generic.IList{Microsoft.Extensions.AI.AIContent}) - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolCallContent.#ctor - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolCallContent.set_CallId(System.String) - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolResultContent.#ctor - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolResultContent.set_CallId(System.String) - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.get_AuthorizationToken - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.set_AuthorizationToken(System.String) - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.#ctor - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.get_ImageId - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.set_ImageId(System.String) - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.#ctor - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.get_ImageId - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.set_ImageId(System.String) - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolCallContent.get_Arguments - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolCallContent.get_ToolName - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolResultContent.get_Output - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolResultContent.set_Output(System.Collections.Generic.IList{Microsoft.Extensions.AI.AIContent}) - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolCallContent.#ctor - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolCallContent.set_CallId(System.String) - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolResultContent.#ctor - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.CodeInterpreterToolResultContent.set_CallId(System.String) - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.get_AuthorizationToken - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.set_AuthorizationToken(System.String) - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.#ctor - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.get_ImageId - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolCallContent.set_ImageId(System.String) - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.#ctor - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.get_ImageId - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ImageGenerationToolResultContent.set_ImageId(System.String) - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolCallContent.get_Arguments - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolCallContent.get_ToolName - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolResultContent.get_Output - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.McpServerToolResultContent.set_Output(System.Collections.Generic.IList{Microsoft.Extensions.AI.AIContent}) - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.IToolReductionStrategy - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.IToolReductionStrategy - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.IToolReductionStrategy - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.IToolReductionStrategy - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.IToolReductionStrategy - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - - \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs index 37acd121960..d4ab07b1eec 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Net.Mime; using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; @@ -18,6 +19,9 @@ namespace Microsoft.Extensions.AI; [DebuggerDisplay("{DebuggerDisplay,nq}")] public class UriContent : AIContent { + /// The default media type for unknown file extensions. + private const string DefaultMediaType = "application/octet-stream"; + /// The URI represented. private Uri _uri; @@ -26,37 +30,35 @@ public class UriContent : AIContent /// Initializes a new instance of the class. /// The URI to the represented content. - /// The media type (also known as MIME type) represented by the content. + /// + /// The media type (also known as MIME type) represented by the content. If not provided, + /// it will be inferred from the file extension of the URI. If it cannot be inferred, + /// "application/octet-stream" is used. + /// /// is . - /// is . /// is an invalid media type. /// is an invalid URL. - /// - /// A media type must be specified, so that consumers know what to do with the content. - /// If an exact media type is not known, but the category (e.g. image) is known, a wildcard - /// may be used (e.g. "image/*"). - /// - public UriContent(string uri, string mediaType) + public UriContent(string uri, string? mediaType = null) : this(new Uri(Throw.IfNull(uri)), mediaType) { } /// Initializes a new instance of the class. /// The URI to the represented content. - /// The media type (also known as MIME type) represented by the content. + /// + /// The media type (also known as MIME type) represented by the content. If not provided, + /// it will be inferred from the file extension of the URI. If it cannot be inferred, + /// "application/octet-stream" is used. + /// /// is . - /// is . /// is an invalid media type. - /// - /// A media type must be specified, so that consumers know what to do with the content. - /// If an exact media type is not known, but the category (e.g. image) is known, a wildcard - /// may be used (e.g. "image/*"). - /// [JsonConstructor] - public UriContent(Uri uri, string mediaType) + public UriContent(Uri uri, string? mediaType = null) { _uri = Throw.IfNull(uri); - _mediaType = DataUriParser.ThrowIfInvalidMediaType(mediaType); + _mediaType = mediaType is not null + ? DataUriParser.ThrowIfInvalidMediaType(mediaType) + : InferMediaType(uri); } /// Gets or sets the for this content. @@ -90,4 +92,25 @@ public string MediaType /// Gets a string representing this instance to display in the debugger. [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => $"Uri = {_uri}"; + + /// Infers the media type from the URI's file extension. + private static string InferMediaType(Uri uri) + { + string path; + if (uri.IsAbsoluteUri) + { + path = uri.AbsolutePath; + } + else + { + path = uri.OriginalString; + int i = path.AsSpan().IndexOfAny('?', '#'); + if (i >= 0) + { + path = path.Substring(0, i); + } + } + + return MediaTypeMap.GetMediaType(path) ?? DefaultMediaType; + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Files/HostedFileDownloadStream.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Files/HostedFileDownloadStream.cs index 91d22450c4f..f72f7eb3355 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Files/HostedFileDownloadStream.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Files/HostedFileDownloadStream.cs @@ -51,6 +51,40 @@ protected HostedFileDownloadStream() /// public virtual string? FileName => null; + /// + public override bool CanWrite => false; + + /// + public override void SetLength(long value) => throw new NotSupportedException(); + + /// + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) => + throw new NotSupportedException(); + + /// + public override void EndWrite(IAsyncResult asyncResult) => throw new NotSupportedException(); + + /// + public override void WriteByte(byte value) => throw new NotSupportedException(); + + /// + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + +#if NET + /// + public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(); +#endif + + /// + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + throw new NotSupportedException(); + +#if NET + /// + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); +#endif + /// /// Reads the entire stream content from its current position and returns it as a . /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs index 203045f92b2..ef8351b9eae 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs @@ -40,6 +40,10 @@ protected AIFunctionDeclaration() /// The metadata present in the schema document plays an important role in guiding AI function invocation. /// /// + /// When an is created via , this schema is automatically derived from the + /// method's parameters using the configured and . + /// + /// /// When no schema is specified, consuming chat clients should assume the "{}" or "true" schema, indicating that any JSON input is admissible. /// /// @@ -47,8 +51,18 @@ protected AIFunctionDeclaration() /// Gets a JSON Schema describing the function's return value. /// - /// A typically reflects a function that doesn't specify a return schema - /// or a function that returns , , or . + /// + /// When an is created via , this schema is automatically derived from the + /// method's return type using the configured and . + /// For methods returning or , the schema is based on the + /// unwrapped result type. Return schema generation can be excluded by setting + /// to . + /// + /// + /// A value typically reflects a function that doesn't specify a return schema, + /// a function that returns , , or , + /// or a function for which was set to . + /// /// public virtual JsonElement? ReturnJsonSchema => null; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 9883531b438..9398805da9a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -27,6 +27,19 @@ namespace Microsoft.Extensions.AI; /// Provides factory methods for creating commonly-used implementations of . +/// +/// +/// The class creates instances that wrap .NET methods +/// (specified as or ). As part of this process, JSON schemas are +/// automatically derived for both the function's input parameters (exposed via ) +/// and, by default, the function's return type (exposed via ). +/// These schemas are produced using the and +/// , and enable AI services to understand and +/// interact with the function. Return value serialization and schema derivation behavior can be customized +/// via and , +/// respectively. +/// +/// /// Invoke .NET functions using an AI model. public static partial class AIFunctionFactory { @@ -98,7 +111,14 @@ public static partial class AIFunctionFactory /// special-cased and are not serialized: the created function returns the original instance(s) directly to enable /// callers (such as an IChatClient) to perform type tests and implement specialized handling. If /// is supplied, that delegate governs the behavior instead. - /// Handling of return values can be overridden via . + /// + /// + /// In addition to the parameter schema, a JSON schema is also derived from the method's return type and exposed via the + /// returned 's . For methods returning + /// , , or , no return schema is produced (the property is ). + /// For methods returning or , the schema is derived from the + /// unwrapped result type. Return schema generation can be excluded via , + /// and its generation is governed by 's . /// /// /// is . @@ -169,6 +189,11 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// derived type of , or any type assignable from are not serialized; /// they are returned as-is to facilitate specialized handling. /// + /// + /// A JSON schema is also derived from the method's return type and exposed via . + /// For methods returning , , or , no return schema is produced. + /// For methods returning or , the schema is derived from the unwrapped result type. + /// /// /// is . /// A parameter to is not serializable. @@ -255,6 +280,14 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// any type assignable from are not serialized and are instead returned directly. /// Handling of return values can be overridden via . /// + /// + /// In addition to the parameter schema, a JSON schema is also derived from the method's return type and exposed via the + /// returned 's . For methods returning + /// , , or , no return schema is produced (the property is ). + /// For methods returning or , the schema is derived from the + /// unwrapped result type. Return schema generation can be excluded via , + /// and its generation is governed by 's . + /// /// /// is . /// represents an instance method but is null. @@ -334,6 +367,11 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// derived type of , or any type assignable from are returned /// without serialization to enable specialized handling. /// + /// + /// A JSON schema is also derived from the method's return type and exposed via . + /// For methods returning , , or , no return schema is produced. + /// For methods returning or , the schema is derived from the unwrapped result type. + /// /// /// is . /// represents an instance method but is null. @@ -433,6 +471,14 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// assignable from are returned directly without serialization. /// Handling of return values can be overridden via . /// + /// + /// In addition to the parameter schema, a JSON schema is also derived from the method's return type and exposed via the + /// returned 's . For methods returning + /// , , or , no return schema is produced (the property is ). + /// For methods returning or , the schema is derived from the + /// unwrapped result type. Return schema generation can be excluded via , + /// and its generation is governed by 's . + /// /// /// is . /// is . diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs index 5caef21900c..bcb552bf242 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs @@ -30,10 +30,13 @@ public AIFunctionFactoryOptions() public JsonSerializerOptions? SerializerOptions { get; set; } /// - /// Gets or sets the governing the generation of JSON schemas for the function. + /// Gets or sets the governing the generation of JSON schemas for + /// the function's input parameters and return type. /// /// /// If no value has been specified, the instance will be used. + /// This setting affects both the (input parameters) and + /// the (return type). /// public AIJsonSchemaCreateOptions? JsonSchemaCreateOptions { get; set; } @@ -107,14 +110,16 @@ public AIFunctionFactoryOptions() public Func>? MarshalResult { get; set; } /// - /// Gets or sets a value indicating whether a schema should be created for the function's result type, if possible, and included as . + /// Gets or sets a value indicating whether to exclude generation of a JSON schema for the function's return type. /// /// /// - /// The default value is . + /// The default value is , meaning a return type schema will be generated and exposed + /// via when the method has a return type other than + /// , , or . /// /// - /// When set to , results in the produced to always be . + /// When set to , the produced will always be . /// /// public bool ExcludeResultSchema { get; set; } 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 d9f97f58c97..da742f97782 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 @@ -3333,11 +3333,11 @@ "Stage": "Stable", "Methods": [ { - "Member": "Microsoft.Extensions.AI.UriContent.UriContent(string uri, string mediaType);", + "Member": "Microsoft.Extensions.AI.UriContent.UriContent(string uri, string? mediaType = null);", "Stage": "Stable" }, { - "Member": "Microsoft.Extensions.AI.UriContent.UriContent(System.Uri uri, string mediaType);", + "Member": "Microsoft.Extensions.AI.UriContent.UriContent(System.Uri uri, string? mediaType = null);", "Stage": "Stable" }, { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/CreateConversationItemRealtimeClientMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/CreateConversationItemRealtimeClientMessage.cs new file mode 100644 index 00000000000..5db5b3b91e7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/CreateConversationItemRealtimeClientMessage.cs @@ -0,0 +1,38 @@ +// 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.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time message for creating a conversation item. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class CreateConversationItemRealtimeClientMessage : RealtimeClientMessage +{ + private RealtimeConversationItem _item; + + /// + /// Initializes a new instance of the class. + /// + /// The conversation item to create. + /// is . + public CreateConversationItemRealtimeClientMessage(RealtimeConversationItem item) + { + _item = Throw.IfNull(item); + } + + /// + /// Gets or sets the conversation item to create. + /// + /// is . + public RealtimeConversationItem Item + { + get => _item; + set => _item = Throw.IfNull(value); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/CreateResponseRealtimeClientMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/CreateResponseRealtimeClientMessage.cs new file mode 100644 index 00000000000..4ee2bc36cb1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/CreateResponseRealtimeClientMessage.cs @@ -0,0 +1,125 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a client message that triggers model inference to generate a response. +/// +/// +/// +/// Sending this message instructs the provider to generate a new response from the model. +/// The response may include one or more output items (text, audio, or tool calls). +/// Properties on this message optionally override the session-level configuration +/// for this response only. +/// +/// +/// Not all providers support explicit response triggering. Voice-activity-detection (VAD) driven +/// providers may respond automatically when speech is detected or input is committed, in which case +/// this message may be treated as a no-op. Per-response overrides (instructions, tools, voice, etc.) +/// are advisory and may be silently ignored by providers that do not support them. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class CreateResponseRealtimeClientMessage : RealtimeClientMessage +{ + /// + /// Initializes a new instance of the class. + /// + public CreateResponseRealtimeClientMessage() + { + } + + /// + /// Gets or sets the list of the conversation items to create a response for. + /// + public IList? Items { get; set; } + + /// + /// Gets or sets the output audio options for the response. + /// + /// + /// If set, overrides the session-level audio output configuration for this response only. + /// If , the session's default audio options are used. + /// + public RealtimeAudioFormat? OutputAudioOptions { get; set; } + + /// + /// Gets or sets the voice of the output audio. + /// + /// + /// If set, overrides the session-level voice for this response only. + /// If , the session's default voice is used. + /// + public string? OutputVoice { get; set; } + + /// + /// Gets or sets a value indicating whether the response output should be excluded from the conversation context. + /// + /// + /// When , the response is generated out-of-band: the model produces output + /// but the resulting items are not added to the conversation history, so they will not appear + /// as context for subsequent responses. + /// If , the provider's default behavior is used. + /// + public bool? ExcludeFromConversation { get; set; } + + /// + /// Gets or sets the instructions that guide the model on desired responses. + /// + /// + /// If set, overrides the session-level instructions for this response only. + /// If , the session's default instructions are used. + /// + public string? Instructions { get; set; } + + /// + /// Gets or sets the maximum number of output tokens for the response, inclusive of all modalities and tool calls. + /// + /// + /// This limit applies to the total output tokens regardless of modality (text, audio, etc.). + /// If , the provider's default limit is used. + /// + public int? MaxOutputTokens { get; set; } + + /// + /// Gets or sets any additional properties associated with the response request. + /// + /// + /// This can be used to attach arbitrary key-value metadata to a response request + /// for tracking or disambiguation purposes (e.g., correlating multiple simultaneous responses). + /// Providers may map this to their own metadata fields. + /// + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// + /// Gets or sets the output modalities for the response (e.g., "text", "audio"). + /// + /// + /// If set, overrides the session-level output modalities for this response only. + /// If , the session's default modalities are used. + /// + public IList? OutputModalities { get; set; } + + /// + /// Gets or sets the tool choice mode for the response. + /// + /// + /// If set, overrides the session-level tool choice for this response only. + /// If , the session's default tool choice is used. + /// + public ChatToolMode? ToolMode { get; set; } + + /// + /// Gets or sets the AI tools available for generating the response. + /// + /// + /// If set, overrides the session-level tools for this response only. + /// If , the session's default tools are used. + /// + public IList? Tools { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/DelegatingRealtimeClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/DelegatingRealtimeClient.cs new file mode 100644 index 00000000000..217f0851264 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/DelegatingRealtimeClient.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +/// +/// This is recommended as a base type when building clients that can be chained around an underlying . +/// The default implementation simply passes each call to the inner client instance. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class DelegatingRealtimeClient : IRealtimeClient +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped client instance. + /// is . + protected DelegatingRealtimeClient(IRealtimeClient innerClient) + { + InnerClient = Throw.IfNull(innerClient); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Gets the inner . + protected IRealtimeClient InnerClient { get; } + + /// + public virtual Task CreateSessionAsync( + RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default) => + InnerClient.CreateSessionAsync(options, cancellationToken); + + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + // If the key is non-null, we don't know what it means so pass through to the inner service. + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerClient.GetService(serviceType, serviceKey); + } + + /// Provides a mechanism for releasing unmanaged resources. + /// if being called from ; otherwise, . + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + InnerClient.Dispose(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ErrorRealtimeServerMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ErrorRealtimeServerMessage.cs new file mode 100644 index 00000000000..8a606f53a82 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ErrorRealtimeServerMessage.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.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time server error message. +/// +/// +/// Used with the . +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class ErrorRealtimeServerMessage : RealtimeServerMessage +{ + /// + /// Initializes a new instance of the class. + /// + public ErrorRealtimeServerMessage() + { + Type = RealtimeServerMessageType.Error; + } + + /// + /// Gets or sets the error content associated with the error message. + /// + public ErrorContent? Error { get; set; } + + /// + /// Gets or sets the ID of the client message that caused the error. + /// + /// + /// Unlike , which identifies this server message itself, + /// this property identifies the originating client message that triggered the error. + /// + public string? OriginatingMessageId { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/IRealtimeClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/IRealtimeClient.cs new file mode 100644 index 00000000000..5ae142326f1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/IRealtimeClient.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents a real-time client. +/// This interface provides methods to create and manage real-time sessions. +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public interface IRealtimeClient : IDisposable +{ + /// Creates a new real-time session with the specified options. + /// The session options. + /// A token to cancel the operation. + /// The created real-time session. + Task CreateSessionAsync(RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default); + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + object? GetService(Type serviceType, object? serviceKey = null); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/IRealtimeClientSession.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/IRealtimeClientSession.cs new file mode 100644 index 00000000000..f27a0b3447a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/IRealtimeClientSession.cs @@ -0,0 +1,63 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents a real-time session. +/// This interface provides methods to manage a real-time session and to interact with the real-time model. +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public interface IRealtimeClientSession : IAsyncDisposable +{ + /// + /// Gets the current session options. + /// + RealtimeSessionOptions? Options { get; } + + /// + /// Sends a client message to the session. + /// + /// The client message to send. + /// A token to cancel the operation. + /// A task that represents the asynchronous send operation. + /// + /// + /// This method allows for sending client messages to the session at any time, which can be used to influence the session's behavior or state. + /// + /// + /// Concurrency note for provider implementers: may be called concurrently + /// from multiple sources. For example, a caller may stream audio via on one thread while + /// middleware such as FunctionInvokingRealtimeClientSession calls to return tool results + /// from within enumeration on another thread. If the underlying transport + /// (e.g., a WebSocket) does not support concurrent sends, provider implementations must serialize access — for + /// example by using a — to prevent protocol violations. + /// + /// + Task SendAsync(RealtimeClientMessage message, CancellationToken cancellationToken = default); + + /// Streams the response from the real-time session. + /// A token to cancel the operation. + /// The response messages generated by the session. + /// + /// This method cannot be called multiple times concurrently on the same session instance. + /// + IAsyncEnumerable GetStreamingResponseAsync( + CancellationToken cancellationToken = default); + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + object? GetService(Type serviceType, object? serviceKey = null); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioBufferAppendRealtimeClientMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioBufferAppendRealtimeClientMessage.cs new file mode 100644 index 00000000000..c255fc689cf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioBufferAppendRealtimeClientMessage.cs @@ -0,0 +1,41 @@ +// 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.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time message for appending audio buffer input. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class InputAudioBufferAppendRealtimeClientMessage : RealtimeClientMessage +{ + private DataContent _content; + + /// + /// Initializes a new instance of the class. + /// + /// The data content containing the audio buffer data to append. + /// is . + public InputAudioBufferAppendRealtimeClientMessage(DataContent audioContent) + { + _content = Throw.IfNull(audioContent); + } + + /// + /// Gets or sets the audio content to append to the model audio buffer. + /// + /// + /// The content should include the audio buffer data that needs to be appended to the input audio buffer. + /// + /// is . + public DataContent Content + { + get => _content; + set => _content = Throw.IfNull(value); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioBufferCommitRealtimeClientMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioBufferCommitRealtimeClientMessage.cs new file mode 100644 index 00000000000..427fbda5ca9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioBufferCommitRealtimeClientMessage.cs @@ -0,0 +1,22 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time message for committing audio buffer input. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class InputAudioBufferCommitRealtimeClientMessage : RealtimeClientMessage +{ + /// + /// Initializes a new instance of the class. + /// + public InputAudioBufferCommitRealtimeClientMessage() + { + } +} + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioTranscriptionRealtimeServerMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioTranscriptionRealtimeServerMessage.cs new file mode 100644 index 00000000000..c50d7c8240f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/InputAudioTranscriptionRealtimeServerMessage.cs @@ -0,0 +1,58 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time server message for input audio transcription. +/// +/// +/// Used when having InputAudioTranscriptionCompleted, InputAudioTranscriptionDelta, or InputAudioTranscriptionFailed response types. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class InputAudioTranscriptionRealtimeServerMessage : RealtimeServerMessage +{ + /// + /// Initializes a new instance of the class. + /// + /// The type of the real-time server response. + /// + /// The parameter should be InputAudioTranscriptionCompleted, InputAudioTranscriptionDelta, or InputAudioTranscriptionFailed. + /// + public InputAudioTranscriptionRealtimeServerMessage(RealtimeServerMessageType type) + { + Type = type; + } + + /// + /// Gets or sets the index of the content part containing the audio. + /// + public int? ContentIndex { get; set; } + + /// + /// Gets or sets the ID of the item containing the audio that is being transcribed. + /// + public string? ItemId { get; set; } + + /// + /// Gets or sets the transcription text of the audio. + /// + public string? Transcription { get; set; } + + /// + /// Gets or sets the transcription-specific usage, which is billed separately from the realtime model. + /// + /// + /// This usage reflects the cost of the speech-to-text transcription and is billed according to the + /// ASR (Automatic Speech Recognition) model's pricing rather than the realtime model's pricing. + /// + public UsageDetails? Usage { get; set; } + + /// + /// Gets or sets the error content if an error occurred during transcription. + /// + public ErrorContent? Error { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/OutputTextAudioRealtimeServerMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/OutputTextAudioRealtimeServerMessage.cs new file mode 100644 index 00000000000..37861c4f76e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/OutputTextAudioRealtimeServerMessage.cs @@ -0,0 +1,73 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time server message for output text and audio. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class OutputTextAudioRealtimeServerMessage : RealtimeServerMessage +{ + /// + /// Initializes a new instance of the class for handling output text delta responses. + /// + /// The type of the real-time server response. + /// + /// The should be , , + /// , , + /// , or . + /// + public OutputTextAudioRealtimeServerMessage(RealtimeServerMessageType type) + { + Type = type; + } + + /// + /// Gets or sets the index of the content part whose text has been updated. + /// + public int? ContentIndex { get; set; } + + /// + /// Gets or sets the text delta or final text content. + /// + /// + /// Populated for , , + /// , and messages. + /// For audio messages ( and ), + /// use instead. + /// + public string? Text { get; set; } + + /// + /// Gets or sets the Base64-encoded audio data delta or final audio content. + /// + /// + /// Populated for messages. + /// For , this is typically + /// as the final audio is not included; use the accumulated deltas instead. + /// For text content, use instead. + /// + public string? Audio { get; set; } + + /// + /// Gets or sets the ID of the item containing the content part whose text has been updated. + /// + public string? ItemId { get; set; } + + /// + /// Gets or sets the index of the output item in the response. + /// + public int? OutputIndex { get; set; } + + /// + /// Gets or sets the ID of the response. + /// + /// + /// May be for providers that do not natively track response lifecycle. + /// + public string? ResponseId { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeAudioFormat.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeAudioFormat.cs new file mode 100644 index 00000000000..c8684185268 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeAudioFormat.cs @@ -0,0 +1,33 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents options for configuring real-time audio. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class RealtimeAudioFormat +{ + /// + /// Initializes a new instance of the class. + /// + public RealtimeAudioFormat(string mediaType, int sampleRate) + { + MediaType = mediaType; + SampleRate = sampleRate; + } + + /// + /// Gets the media type of the audio (e.g., "audio/pcm", "audio/pcmu", "audio/pcma"). + /// + public string MediaType { get; init; } + + /// + /// Gets the sample rate of the audio in Hertz. + /// + public int SampleRate { get; init; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeClientMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeClientMessage.cs new file mode 100644 index 00000000000..0f035933462 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeClientMessage.cs @@ -0,0 +1,30 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time message the client sends to the model. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class RealtimeClientMessage +{ + /// + /// Gets or sets the optional message ID associated with the message. + /// This can be used for tracking and correlation purposes. + /// + public string? MessageId { get; set; } + + /// + /// Gets or sets the raw representation of the message. + /// This can be used to send the raw data to the model. + /// + /// + /// The raw representation is typically used for custom or unsupported message types. + /// For example, the model may accept a JSON serialized message. + /// + public object? RawRepresentation { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeConversationItem.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeConversationItem.cs new file mode 100644 index 00000000000..7373a5d6773 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeConversationItem.cs @@ -0,0 +1,61 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time conversation item. +/// +/// +/// This class is used to encapsulate the details of a real-time item that can be inserted into a conversation, +/// or sent as part of a real-time response creation process. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class RealtimeConversationItem +{ + /// + /// Initializes a new instance of the class. + /// + /// The contents of the conversation item. + /// The ID of the conversation item. + /// The role of the conversation item. + public RealtimeConversationItem(IList contents, string? id = null, ChatRole? role = null) + { + Id = id; + Role = role; + Contents = contents; + } + + /// + /// Gets or sets the ID of the conversation item. + /// + /// + /// This ID can be null in case passing Function or MCP content where the ID is not required. + /// The Id only needed of having contents representing a user, system, or assistant message with contents like text, audio, image or similar. + /// + public string? Id { get; set; } + + /// + /// Gets or sets the role of the conversation item. + /// + /// + /// The role not used in case of Function or MCP content. + /// The role only needed of having contents representing a user, system, or assistant message with contents like text, audio, image or similar. + /// + public ChatRole? Role { get; set; } + + /// + /// Gets or sets the content of the conversation item. + /// + public IList Contents { get; set; } + + /// + /// Gets or sets the raw representation of the conversation item. + /// This can be used to hold the original data structure received from or sent to the provider. + /// + public object? RawRepresentation { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeResponseStatus.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeResponseStatus.cs new file mode 100644 index 00000000000..f89fcd4224c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeResponseStatus.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.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Defines well-known status values for real-time response lifecycle messages. +/// +/// +/// These constants represent the standard status values that may appear on +/// when the response completes +/// (i.e., on ). +/// Providers may use additional status values beyond those defined here. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public static class RealtimeResponseStatus +{ + /// + /// Gets the status value indicating the response completed successfully. + /// + public static string Completed { get; } = "completed"; + + /// + /// Gets the status value indicating the response was cancelled, typically due to an interruption such as user barge-in + /// (the user started speaking while the model was generating output). + /// + public static string Cancelled { get; } = "cancelled"; + + /// + /// Gets the status value indicating the response ended before completing, for example because the output reached + /// the maximum token limit. + /// + public static string Incomplete { get; } = "incomplete"; + + /// + /// Gets the status value indicating the response failed due to an error. + /// + public static string Failed { get; } = "failed"; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeServerMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeServerMessage.cs new file mode 100644 index 00000000000..0e023fde4f4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeServerMessage.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.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time server response message. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class RealtimeServerMessage +{ + /// + /// Gets or sets the type of the real-time response. + /// + public RealtimeServerMessageType Type { get; set; } + + /// + /// Gets or sets the optional message ID associated with the response. + /// This can be used for tracking and correlation purposes. + /// + public string? MessageId { get; set; } + + /// + /// Gets or sets the raw representation of the response. + /// This can be used to hold the original data structure received from the model. + /// + /// + /// The raw representation is typically used for custom or unsupported message types. + /// For example, the model may accept a JSON serialized server message. + /// + public object? RawRepresentation { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeServerMessageType.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeServerMessageType.cs new file mode 100644 index 00000000000..e7c9f33c65c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeServerMessageType.cs @@ -0,0 +1,163 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the type of a real-time server message. +/// This is used to identify the message type being received from the model. +/// +/// +/// +/// Well-known message types are provided as static properties. Providers may define additional +/// message types by constructing new instances with custom values. +/// +/// +/// Provider implementations that want to support the built-in middleware pipeline +/// ( and +/// ) must emit the following +/// message types at appropriate points during response generation: +/// +/// — when the model begins generating a new response. +/// — when the model has finished generating a response (with usage data if available). +/// — when a new output item (e.g., function call, message) is added during response generation. +/// — when an individual output item has completed. This is required for function invocation middleware to detect and invoke tool calls. +/// +/// +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct RealtimeServerMessageType : IEquatable +{ + /// Gets a message type indicating that the response contains only raw content. + /// + /// This type supports extensibility for custom content types not natively supported by the SDK. + /// + public static RealtimeServerMessageType RawContentOnly { get; } = new("RawContentOnly"); + + /// Gets a message type indicating the output of audio transcription for user audio written to the user audio buffer. + public static RealtimeServerMessageType InputAudioTranscriptionCompleted { get; } = new("InputAudioTranscriptionCompleted"); + + /// Gets a message type indicating the text value of an input audio transcription content part is updated with incremental transcription results. + public static RealtimeServerMessageType InputAudioTranscriptionDelta { get; } = new("InputAudioTranscriptionDelta"); + + /// Gets a message type indicating that the audio transcription for user audio written to the user audio buffer has failed. + public static RealtimeServerMessageType InputAudioTranscriptionFailed { get; } = new("InputAudioTranscriptionFailed"); + + /// Gets a message type indicating the output text update with incremental results. + public static RealtimeServerMessageType OutputTextDelta { get; } = new("OutputTextDelta"); + + /// Gets a message type indicating the output text is complete. + public static RealtimeServerMessageType OutputTextDone { get; } = new("OutputTextDone"); + + /// Gets a message type indicating the model-generated transcription of audio output updated. + public static RealtimeServerMessageType OutputAudioTranscriptionDelta { get; } = new("OutputAudioTranscriptionDelta"); + + /// Gets a message type indicating the model-generated transcription of audio output is done streaming. + public static RealtimeServerMessageType OutputAudioTranscriptionDone { get; } = new("OutputAudioTranscriptionDone"); + + /// Gets a message type indicating the audio output updated. + public static RealtimeServerMessageType OutputAudioDelta { get; } = new("OutputAudioDelta"); + + /// Gets a message type indicating the audio output is done streaming. + public static RealtimeServerMessageType OutputAudioDone { get; } = new("OutputAudioDone"); + + /// Gets a message type indicating the response has completed. + public static RealtimeServerMessageType ResponseDone { get; } = new("ResponseDone"); + + /// Gets a message type indicating the response has been created. + public static RealtimeServerMessageType ResponseCreated { get; } = new("ResponseCreated"); + + /// Gets a message type indicating an individual output item in the response has completed. + public static RealtimeServerMessageType ResponseOutputItemDone { get; } = new("ResponseOutputItemDone"); + + /// Gets a message type indicating an individual output item has been added to the response. + public static RealtimeServerMessageType ResponseOutputItemAdded { get; } = new("ResponseOutputItemAdded"); + + /// Gets a message type indicating a conversation item has been added. + public static RealtimeServerMessageType ConversationItemAdded { get; } = new("ConversationItemAdded"); + + /// Gets a message type indicating a conversation item is complete. + public static RealtimeServerMessageType ConversationItemDone { get; } = new("ConversationItemDone"); + + /// Gets a message type indicating an error occurred while processing the request. + public static RealtimeServerMessageType Error { get; } = new("Error"); + + /// + /// Gets the value associated with this . + /// + public string Value { get; } + + /// + /// Initializes a new instance of the struct with the provided value. + /// + /// The value to associate with this . + /// is or whitespace. + [JsonConstructor] + public RealtimeServerMessageType(string value) + { + Value = Throw.IfNullOrWhitespace(value); + } + + /// + /// Returns a value indicating whether two instances are equivalent, as determined by a + /// case-insensitive comparison of their values. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if left and right have equivalent values; otherwise, . + public static bool operator ==(RealtimeServerMessageType left, RealtimeServerMessageType right) + { + return left.Equals(right); + } + + /// + /// Returns a value indicating whether two instances are not equivalent, as determined by a + /// case-insensitive comparison of their values. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if left and right have different values; otherwise, . + public static bool operator !=(RealtimeServerMessageType left, RealtimeServerMessageType right) + { + return !(left == right); + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + => obj is RealtimeServerMessageType other && Equals(other); + + /// + public bool Equals(RealtimeServerMessageType other) + => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() + => Value is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value ?? string.Empty; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override RealtimeServerMessageType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new(reader.GetString()!); + + /// + public override void Write(Utf8JsonWriter writer, RealtimeServerMessageType value, JsonSerializerOptions options) => + Throw.IfNull(writer).WriteStringValue(value.Value); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeSessionKind.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeSessionKind.cs new file mode 100644 index 00000000000..7d79aeb68ed --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeSessionKind.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the kind of a real-time session. +/// +/// +/// Well-known session kinds are provided as static properties. Providers may define additional +/// session kinds by constructing new instances with custom values. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct RealtimeSessionKind : IEquatable +{ + /// + /// Gets a session kind representing a conversational session which processes audio, text, or other media in real-time. + /// + public static RealtimeSessionKind Conversation { get; } = new("conversation"); + + /// + /// Gets a session kind representing a transcription-only session. + /// + public static RealtimeSessionKind Transcription { get; } = new("transcription"); + + /// Gets the value of the session kind. + public string Value { get; } + + /// Initializes a new instance of the struct with the provided value. + /// The value to associate with this . + /// is or whitespace. + [JsonConstructor] + public RealtimeSessionKind(string value) + { + Value = Throw.IfNullOrWhitespace(value); + } + + /// + /// Returns a value indicating whether two instances are equivalent, as determined by a + /// case-insensitive comparison of their values. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if left and right have equivalent values; otherwise, . + public static bool operator ==(RealtimeSessionKind left, RealtimeSessionKind right) + { + return left.Equals(right); + } + + /// + /// Returns a value indicating whether two instances are not equivalent, as determined by a + /// case-insensitive comparison of their values. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if left and right have different values; otherwise, . + public static bool operator !=(RealtimeSessionKind left, RealtimeSessionKind right) + { + return !(left == right); + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + => obj is RealtimeSessionKind other && Equals(other); + + /// + public bool Equals(RealtimeSessionKind other) + => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() + => Value is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value ?? string.Empty; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override RealtimeSessionKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new(reader.GetString()!); + + /// + public override void Write(Utf8JsonWriter writer, RealtimeSessionKind value, JsonSerializerOptions options) => + Throw.IfNull(writer).WriteStringValue(value.Value); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeSessionOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeSessionOptions.cs new file mode 100644 index 00000000000..61326c517bd --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/RealtimeSessionOptions.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; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents options for configuring a real-time session. +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class RealtimeSessionOptions +{ + /// + /// Gets the session kind. + /// + /// + /// If set to , most of the sessions properties will not apply to the session. Only InputAudioFormat and TranscriptionOptions will be used. + /// + public RealtimeSessionKind SessionKind { get; init; } = RealtimeSessionKind.Conversation; + + /// + /// Gets the model name to use for the session. + /// + public string? Model { get; init; } + + /// + /// Gets the input audio format for the session. + /// + public RealtimeAudioFormat? InputAudioFormat { get; init; } + + /// + /// Gets the transcription options for the session. + /// + public TranscriptionOptions? TranscriptionOptions { get; init; } + + /// + /// Gets the output audio format for the session. + /// + public RealtimeAudioFormat? OutputAudioFormat { get; init; } + + /// + /// Gets the output voice for the session. + /// + public string? Voice { get; init; } + + /// + /// Gets the default system instructions for the session. + /// + public string? Instructions { get; init; } + + /// + /// Gets the maximum number of response tokens for the session. + /// + public int? MaxOutputTokens { get; init; } + + /// + /// Gets the output modalities for the response. like "text", "audio". + /// If null, then default conversation modalities will be used. + /// + public IReadOnlyList? OutputModalities { get; init; } + + /// + /// Gets the tool choice mode for the session. + /// + public ChatToolMode? ToolMode { get; init; } + + /// + /// Gets the AI tools available for generating the response. + /// + public IReadOnlyList? Tools { get; init; } + + /// + /// Gets the voice activity detection (VAD) options for the session. + /// + /// + /// When set, configures how the server detects user speech to manage turn-taking. + /// When , the provider's default VAD behavior is used. + /// + public VoiceActivityDetectionOptions? VoiceActivityDetection { get; init; } + + /// + /// Gets a callback responsible for creating the raw representation of the session options from an underlying implementation. + /// + /// + /// The underlying implementation might have its own representation of options. + /// When a is sent with a , + /// that implementation might convert the provided options into its own representation in order to use it while + /// performing the operation. For situations where a consumer knows which concrete + /// is being used and how it represents options, a new instance of that implementation-specific options type can be + /// returned by this callback for the implementation to use, instead of creating a + /// new instance. Such implementations might mutate the supplied options instance further based on other settings + /// supplied on this instance or from other inputs. + /// Therefore, it is strongly recommended to not return shared instances and instead make the callback return + /// a new instance on each call. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed + /// properties on . + /// + /// Unlike similar factories on other options types, this callback does not receive the session instance + /// as a parameter because some providers need to evaluate it before the session is created + /// (e.g., to produce connection configuration). + /// + /// + [JsonIgnore] + public Func? RawRepresentationFactory { get; init; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ResponseCreatedRealtimeServerMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ResponseCreatedRealtimeServerMessage.cs new file mode 100644 index 00000000000..517b9f8dc04 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ResponseCreatedRealtimeServerMessage.cs @@ -0,0 +1,119 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time message for creating a response item. +/// +/// +/// +/// Used with the and messages. +/// +/// +/// Provider implementations should emit this message with +/// when the model begins generating a new response, and with +/// when the response is complete. The built-in middleware depends +/// on these messages for tracing response lifecycle. +/// +/// +/// Providers that do not natively support response lifecycle events (e.g., those that only stream content parts +/// and signal turn completion) should synthesize these messages to ensure correct middleware behavior. +/// In such cases, may be set to a synthetic value or left . +/// +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class ResponseCreatedRealtimeServerMessage : RealtimeServerMessage +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The should be or . + /// + public ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType type) + { + Type = type; + } + + /// + /// Gets or sets the output audio options for the response. If null, the default conversation audio options will be used. + /// + public RealtimeAudioFormat? OutputAudioOptions { get; set; } + + /// + /// Gets or sets the voice of the output audio. + /// + public string? OutputVoice { get; set; } + + /// + /// Gets or sets the unique response ID. + /// + /// + /// Some providers (e.g., OpenAI) assign a unique ID to each response. Providers that do not + /// natively track response lifecycles may set this to or generate a synthetic ID. + /// Consumers should not assume this value correlates to a provider-specific concept. + /// + public string? ResponseId { get; set; } + + /// + /// Gets or sets the maximum number of output tokens for the response, inclusive of all modalities and tool calls. + /// + /// + /// This limit applies to the total output tokens regardless of modality (text, audio, etc.). + /// If , the provider's default limit was used. + /// + public int? MaxOutputTokens { get; set; } + + /// + /// Gets or sets any additional properties associated with the response. + /// + /// + /// Contains arbitrary key-value metadata attached to the response. + /// This is the metadata that was provided when the response was created + /// (e.g., for tracking or disambiguating multiple simultaneous responses). + /// + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// + /// Gets or sets the list of the conversation items included in the response. + /// + public IList? Items { get; set; } + + /// + /// Gets or sets the output modalities for the response. like "text", "audio". + /// If null, then default conversation modalities will be used. + /// + public IList? OutputModalities { get; set; } + + /// + /// Gets or sets the status of the response. + /// + /// + /// Typically set on messages to indicate + /// how the response ended. See for well-known values + /// such as , + /// (e.g., due to user barge-in), , + /// and . + /// + public string? Status { get; set; } + + /// + /// Gets or sets the error content of the response, if any. + /// + public ErrorContent? Error { get; set; } + + /// + /// Gets or sets the per-response token usage for billing purposes. + /// + /// + /// Populated when the response is complete (i.e., on ). + /// Input tokens include the entire conversation context, so they grow over successive turns + /// as previous output becomes input for later responses. + /// + public UsageDetails? Usage { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ResponseOutputItemRealtimeServerMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ResponseOutputItemRealtimeServerMessage.cs new file mode 100644 index 00000000000..bd2d5ecbafb --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/ResponseOutputItemRealtimeServerMessage.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 System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a real-time message representing a new output item added or created during response generation. +/// +/// +/// +/// Used with the and messages. +/// +/// +/// Provider implementations should emit this message with +/// when an output item (such as a function call or text message) has completed. The built-in +/// middleware depends on this message to detect +/// and invoke tool calls. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class ResponseOutputItemRealtimeServerMessage : RealtimeServerMessage +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The should be or . + /// + public ResponseOutputItemRealtimeServerMessage(RealtimeServerMessageType type) + { + Type = type; + } + + /// + /// Gets or sets the unique response ID. + /// + /// + /// May be for providers that do not natively track response lifecycle. + /// + public string? ResponseId { get; set; } + + /// + /// Gets or sets the unique output index. + /// + public int? OutputIndex { get; set; } + + /// + /// Gets or sets the conversation item included in the response. + /// + public RealtimeConversationItem? Item { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/SessionUpdateRealtimeClientMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/SessionUpdateRealtimeClientMessage.cs new file mode 100644 index 00000000000..f07ec862dd4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/SessionUpdateRealtimeClientMessage.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; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a client message that requests updating the session configuration. +/// +/// +/// +/// Sending this message requests that the provider update the active session with new options. +/// Not all providers support mid-session updates. Providers that do not support this message +/// may ignore it or throw a . +/// +/// +/// When a provider processes this message, it should update its +/// property to reflect the new configuration. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class SessionUpdateRealtimeClientMessage : RealtimeClientMessage +{ + /// + /// Initializes a new instance of the class. + /// + /// The session options to apply. + /// is . + public SessionUpdateRealtimeClientMessage(RealtimeSessionOptions options) + { + Options = Throw.IfNull(options); + } + + /// + /// Gets or sets the session options to apply. + /// + public RealtimeSessionOptions Options { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/VoiceActivityDetectionOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/VoiceActivityDetectionOptions.cs new file mode 100644 index 00000000000..7c120822786 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Realtime/VoiceActivityDetectionOptions.cs @@ -0,0 +1,57 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents options for configuring voice activity detection (VAD) in a real-time session. +/// +/// +/// Voice activity detection automatically determines when a user starts and stops speaking, +/// enabling natural turn-taking in conversational audio interactions. +/// When is , the server detects speech boundaries +/// and manages turn transitions automatically. +/// When is , the client must explicitly signal +/// activity boundaries (e.g., via audio buffer commit and response creation). +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public class VoiceActivityDetectionOptions +{ + /// + /// Initializes a new instance of the class. + /// + public VoiceActivityDetectionOptions() + { + } + + /// + /// Gets or sets a value indicating whether server-side voice activity detection is enabled. + /// + /// + /// When , the server automatically detects speech start and end, + /// and may automatically trigger responses when the user stops speaking. + /// When , turn detection is fully disabled and the client controls + /// turn boundaries manually (e.g., via audio buffer commit and explicit response creation). + /// Other properties on this class, such as , only take effect + /// when this property is . + /// The default is . + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the user's speech can interrupt the model's audio output. + /// + /// + /// This property is only meaningful when is . + /// When voice activity detection is disabled, the server does not detect speech, so interruption + /// does not apply. + /// When , the model's response will be cut off when the user starts speaking (barge-in). + /// When , the model's response will continue to completion regardless of user input. + /// The default is . + /// Not all providers support this option; those that do not will ignore it. + /// + public bool AllowInterruption { get; set; } = true; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/TranscriptionOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/TranscriptionOptions.cs new file mode 100644 index 00000000000..876c71c2aca --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/TranscriptionOptions.cs @@ -0,0 +1,40 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents options for configuring transcription. +/// +[Experimental(DiagnosticIds.Experiments.AISpeechToText, UrlFormat = DiagnosticIds.UrlFormat)] +public class TranscriptionOptions +{ + /// + /// Initializes a new instance of the class. + /// + public TranscriptionOptions() + { + } + + /// + /// Gets or sets the language of the input speech audio. + /// + /// + /// The language should be specified in ISO-639-1 format (e.g. "en"). + /// Supplying the input speech language improves transcription accuracy and latency. + /// + public string? SpeechLanguage { get; set; } + + /// + /// Gets or sets the model ID to use for transcription. + /// + public string? ModelId { get; set; } + + /// + /// Gets or sets an optional prompt to guide the transcription. + /// + public string? Prompt { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/DelegatingTextToSpeechClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/DelegatingTextToSpeechClient.cs new file mode 100644 index 00000000000..1012cefd2f9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/DelegatingTextToSpeechClient.cs @@ -0,0 +1,77 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +/// +/// This is recommended as a base type when building clients that can be chained in any order around an underlying . +/// The default implementation simply passes each call to the inner client instance. +/// +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public class DelegatingTextToSpeechClient : ITextToSpeechClient +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped client instance. + protected DelegatingTextToSpeechClient(ITextToSpeechClient innerClient) + { + InnerClient = Throw.IfNull(innerClient); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Gets the inner . + protected ITextToSpeechClient InnerClient { get; } + + /// + public virtual Task GetAudioAsync( + string text, TextToSpeechOptions? options = null, CancellationToken cancellationToken = default) + { + return InnerClient.GetAudioAsync(text, options, cancellationToken); + } + + /// + public virtual IAsyncEnumerable GetStreamingAudioAsync( + string text, TextToSpeechOptions? options = null, CancellationToken cancellationToken = default) + { + return InnerClient.GetStreamingAudioAsync(text, options, cancellationToken); + } + + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + // If the key is non-null, we don't know what it means so pass through to the inner service. + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerClient.GetService(serviceType, serviceKey); + } + + /// Provides a mechanism for releasing unmanaged resources. + /// if being called from ; otherwise, . + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + InnerClient.Dispose(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/ITextToSpeechClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/ITextToSpeechClient.cs new file mode 100644 index 00000000000..6d3f50350ff --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/ITextToSpeechClient.cs @@ -0,0 +1,62 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents a text to speech client. +/// +/// +/// Unless otherwise specified, all members of are thread-safe for concurrent use. +/// It is expected that all implementations of support being used by multiple requests concurrently. +/// +/// +/// However, implementations of might mutate the arguments supplied to and +/// , such as by configuring the options instance. Thus, consumers of the interface either should avoid +/// using shared instances of these arguments for concurrent invocations or should otherwise ensure by construction that no +/// instances are used which might employ such mutation. For example, the ConfigureOptions method may be +/// provided with a callback that could mutate the supplied options argument, and that should be avoided if using a singleton options instance. +/// +/// +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public interface ITextToSpeechClient : IDisposable +{ + /// Sends text content to the model and returns the generated audio speech. + /// The text to synthesize into speech. + /// The text to speech options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// The audio speech generated. + /// is . + Task GetAudioAsync( + string text, + TextToSpeechOptions? options = null, + CancellationToken cancellationToken = default); + + /// Sends text content to the model and streams back the generated audio speech. + /// The text to synthesize into speech. + /// The text to speech options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// The audio speech updates representing the streamed output. + /// is . + IAsyncEnumerable GetStreamingAudioAsync( + string text, + TextToSpeechOptions? options = null, + CancellationToken cancellationToken = default); + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + object? GetService(Type serviceType, object? serviceKey = null); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechClientExtensions.cs new file mode 100644 index 00000000000..9fd02250a5b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechClientExtensions.cs @@ -0,0 +1,29 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Extensions for . +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public static class TextToSpeechClientExtensions +{ + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// The client. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService? GetService(this ITextToSpeechClient client, object? serviceKey = null) + { + _ = Throw.IfNull(client); + + return (TService?)client.GetService(typeof(TService), serviceKey); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechClientMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechClientMetadata.cs new file mode 100644 index 00000000000..ab0a160447b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechClientMetadata.cs @@ -0,0 +1,44 @@ +// 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.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Provides metadata about an . +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public class TextToSpeechClientMetadata +{ + /// Initializes a new instance of the class. + /// + /// The name of the text to speech provider, if applicable. Where possible, this should map to the + /// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// The URL for accessing the text to speech provider, if applicable. + /// The ID of the text to speech model used by default, if applicable. + public TextToSpeechClientMetadata(string? providerName = null, Uri? providerUri = null, string? defaultModelId = null) + { + DefaultModelId = defaultModelId; + ProviderName = providerName; + ProviderUri = providerUri; + } + + /// Gets the name of the text to speech provider. + /// + /// Where possible, this maps to the appropriate name defined in the + /// OpenTelemetry Semantic Conventions for Generative AI systems. + /// + public string? ProviderName { get; } + + /// Gets the URL for accessing the text to speech provider. + public Uri? ProviderUri { get; } + + /// Gets the ID of the default model used by this text to speech client. + /// + /// This value can be null if either the name is unknown or there are multiple possible models associated with this instance. + /// An individual request may override this value via . + /// + public string? DefaultModelId { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechOptions.cs new file mode 100644 index 00000000000..6cc4645899c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechOptions.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents the options for a text to speech request. +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public class TextToSpeechOptions +{ + /// Initializes a new instance of the class. + public TextToSpeechOptions() + { + } + + /// Initializes a new instance of the class, performing a shallow copy of all properties from . + protected TextToSpeechOptions(TextToSpeechOptions? other) + { + if (other is null) + { + return; + } + + AdditionalProperties = other.AdditionalProperties?.Clone(); + AudioFormat = other.AudioFormat; + Language = other.Language; + ModelId = other.ModelId; + Pitch = other.Pitch; + RawRepresentationFactory = other.RawRepresentationFactory; + Speed = other.Speed; + VoiceId = other.VoiceId; + Volume = other.Volume; + } + + /// Gets or sets the model ID for the text to speech request. + public string? ModelId { get; set; } + + /// Gets or sets the voice identifier to use for speech synthesis. + public string? VoiceId { get; set; } + + /// Gets or sets the language for the generated speech. + /// + /// This is typically a BCP 47 language tag (e.g., "en-US", "fr-FR"). + /// + public string? Language { get; set; } + + /// Gets or sets the desired audio output format. + /// + /// This may be a media type (e.g., "audio/mpeg") or a provider-specific format name (e.g., "mp3", "wav", "opus"). + /// When not specified, the provider's default format is used. + /// + public string? AudioFormat { get; set; } + + /// Gets or sets the speech speed multiplier. + /// + /// A value of 1.0 represents normal speed. Values greater than 1.0 increase speed; values less than 1.0 decrease speed. + /// The valid range is provider-specific. + /// + public float? Speed { get; set; } + + /// Gets or sets the speech pitch multiplier. + /// + /// A value of 1.0 represents normal pitch. Values greater than 1.0 increase pitch; values less than 1.0 decrease pitch. + /// The valid range is provider-specific. + /// + public float? Pitch { get; set; } + + /// Gets or sets the speech volume level. + /// + /// The valid range and interpretation is provider-specific; a common convention is 0.0 (silent) to 1.0 (full volume). + /// + public float? Volume { get; set; } + + /// Gets or sets any additional properties associated with the options. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// + /// Gets or sets a callback responsible for creating the raw representation of the text to speech options from an underlying implementation. + /// + /// + /// The underlying implementation may have its own representation of options. + /// When or + /// is invoked with a , that implementation may convert the provided options into + /// its own representation in order to use it while performing the operation. For situations where a consumer knows + /// which concrete is being used and how it represents options, a new instance of that + /// implementation-specific options type may be returned by this callback, for the + /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options + /// instance further based on other settings supplied on this instance or from other inputs, + /// therefore, it is strongly recommended to not return shared instances and instead make the callback return a new instance on each call. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed + /// properties on . + /// + [JsonIgnore] + public Func? RawRepresentationFactory { get; set; } + + /// Produces a clone of the current instance. + /// A clone of the current instance. + public virtual TextToSpeechOptions Clone() => new(this); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponse.cs new file mode 100644 index 00000000000..e769177d64c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponse.cs @@ -0,0 +1,80 @@ +// 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; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents the result of a text to speech request. +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public class TextToSpeechResponse +{ + /// Initializes a new instance of the class. + [JsonConstructor] + public TextToSpeechResponse() + { + } + + /// Initializes a new instance of the class. + /// The contents for this response. + public TextToSpeechResponse(IList contents) + { + Contents = Throw.IfNull(contents); + } + + /// Gets or sets the ID of the text to speech response. + public string? ResponseId { get; set; } + + /// Gets or sets the model ID used in the creation of the text to speech response. + public string? ModelId { get; set; } + + /// Gets or sets the raw representation of the text to speech response from an underlying implementation. + /// + /// If a is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// Gets or sets any additional properties associated with the text to speech response. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Creates an array of instances that represent this . + /// An array of instances that may be used to represent this . + public TextToSpeechResponseUpdate[] ToTextToSpeechResponseUpdates() + { + IList contents = Contents; + if (Usage is { } usage) + { + contents = [.. contents, new UsageContent(usage)]; + } + + TextToSpeechResponseUpdate update = new() + { + Contents = contents, + AdditionalProperties = AdditionalProperties, + RawRepresentation = RawRepresentation, + Kind = TextToSpeechResponseUpdateKind.AudioUpdated, + ResponseId = ResponseId, + ModelId = ModelId, + }; + + return [update]; + } + + /// Gets or sets the generated content items. + [AllowNull] + public IList Contents + { + get => field ??= []; + set; + } + + /// Gets or sets usage details for the text to speech response. + public UsageDetails? Usage { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdate.cs new file mode 100644 index 00000000000..173b8c9cd07 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdate.cs @@ -0,0 +1,75 @@ +// 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; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a single streaming response chunk from an . +/// +/// +/// is so named because it represents streaming updates +/// to a text to speech generation. As such, it is considered erroneous for multiple updates that are part +/// of the same request to contain competing values. For example, some updates that are part of +/// the same request may have a value, and others may have a non- value, +/// but all of those with a non- value must have the same value (e.g. ). +/// +/// +/// The relationship between and is +/// codified in the and +/// , which enable bidirectional conversions +/// between the two. Note, however, that the conversion may be slightly lossy, for example if multiple updates +/// all have different objects whereas there's +/// only one slot for such an object available in . +/// +/// +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public class TextToSpeechResponseUpdate +{ + /// Initializes a new instance of the class. + [JsonConstructor] + public TextToSpeechResponseUpdate() + { + } + + /// Initializes a new instance of the class. + /// The contents for this update. + public TextToSpeechResponseUpdate(IList contents) + { + Contents = Throw.IfNull(contents); + } + + /// Gets or sets the kind of the generated audio speech update. + public TextToSpeechResponseUpdateKind Kind { get; set; } = TextToSpeechResponseUpdateKind.AudioUpdating; + + /// Gets or sets the ID of the generated audio speech response of which this update is a part. + public string? ResponseId { get; set; } + + /// Gets or sets the model ID used in the creation of the text to speech of which this update is a part. + public string? ModelId { get; set; } + + /// Gets or sets the raw representation of the generated audio speech update from an underlying implementation. + /// + /// If a is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// Gets or sets additional properties for the update. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Gets or sets the generated content items. + [AllowNull] + public IList Contents + { + get => field ??= []; + set; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdateExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdateExtensions.cs new file mode 100644 index 00000000000..21d3b0b9e39 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdateExtensions.cs @@ -0,0 +1,109 @@ +// 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; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods for working with instances. +/// +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public static class TextToSpeechResponseUpdateExtensions +{ + /// Combines instances into a single . + /// The updates to be combined. + /// The combined . + public static TextToSpeechResponse ToTextToSpeechResponse( + this IEnumerable updates) + { + _ = Throw.IfNull(updates); + + TextToSpeechResponse response = new(); + + foreach (var update in updates) + { + ProcessUpdate(update, response); + } + + return response; + } + + /// Combines instances into a single . + /// The updates to be combined. + /// The to monitor for cancellation requests. The default is . + /// The combined . + public static Task ToTextToSpeechResponseAsync( + this IAsyncEnumerable updates, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(updates); + + return ToResponseAsync(updates, cancellationToken); + + static async Task ToResponseAsync( + IAsyncEnumerable updates, CancellationToken cancellationToken) + { + TextToSpeechResponse response = new(); + + await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + ProcessUpdate(update, response); + } + + return response; + } + } + + /// Processes the , incorporating its contents and properties. + /// The update to process. + /// The object that should be updated based on . + private static void ProcessUpdate( + TextToSpeechResponseUpdate update, + TextToSpeechResponse response) + { + if (update.ResponseId is not null) + { + response.ResponseId = update.ResponseId; + } + + if (update.ModelId is not null) + { + response.ModelId = update.ModelId; + } + + foreach (var content in update.Contents) + { + switch (content) + { + // Usage content is treated specially and propagated to the response's Usage. + case UsageContent usage: + (response.Usage ??= new()).Add(usage.Details); + break; + + default: + response.Contents.Add(content); + break; + } + } + + if (update.AdditionalProperties is not null) + { + if (response.AdditionalProperties is null) + { + response.AdditionalProperties = new(update.AdditionalProperties); + } + else + { + foreach (var entry in update.AdditionalProperties) + { + response.AdditionalProperties[entry.Key] = entry.Value; + } + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdateKind.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdateKind.cs new file mode 100644 index 00000000000..93338614978 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/TextToSpeech/TextToSpeechResponseUpdateKind.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Describes the intended purpose of a specific update during streaming of text to speech updates. +/// +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +[JsonConverter(typeof(Converter))] +public readonly struct TextToSpeechResponseUpdateKind : IEquatable +{ + /// Gets when the generated audio speech session is opened. + public static TextToSpeechResponseUpdateKind SessionOpen { get; } = new("sessionopen"); + + /// Gets when a non-blocking error occurs during text to speech updates. + public static TextToSpeechResponseUpdateKind Error { get; } = new("error"); + + /// Gets when the audio update is in progress. + public static TextToSpeechResponseUpdateKind AudioUpdating { get; } = new("audioupdating"); + + /// Gets when an audio chunk has been fully generated. + public static TextToSpeechResponseUpdateKind AudioUpdated { get; } = new("audioupdated"); + + /// Gets when the generated audio speech session is closed. + public static TextToSpeechResponseUpdateKind SessionClose { get; } = new("sessionclose"); + + /// + /// Gets the value associated with this . + /// + /// + /// The value will be serialized into the "kind" message field of the text to speech update format. + /// + public string Value { get; } + + /// + /// Initializes a new instance of the struct with the provided value. + /// + /// The value to associate with this . + [JsonConstructor] + public TextToSpeechResponseUpdateKind(string value) + { + Value = Throw.IfNullOrWhitespace(value); + } + + /// + /// Returns a value indicating whether two instances are equivalent, as determined by a + /// case-insensitive comparison of their values. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if left and right are both null or have equivalent values; otherwise, . + public static bool operator ==(TextToSpeechResponseUpdateKind left, TextToSpeechResponseUpdateKind right) + { + return left.Equals(right); + } + + /// + /// Returns a value indicating whether two instances are not equivalent, as determined by a + /// case-insensitive comparison of their values. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if left and right have different values; if they have equivalent values or are both null. + public static bool operator !=(TextToSpeechResponseUpdateKind left, TextToSpeechResponseUpdateKind right) + { + return !(left == right); + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + => obj is TextToSpeechResponseUpdateKind otherKind && Equals(otherKind); + + /// + public bool Equals(TextToSpeechResponseUpdateKind other) + => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() + => Value is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override TextToSpeechResponseUpdateKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new(reader.GetString()!); + + /// + public override void Write(Utf8JsonWriter writer, TextToSpeechResponseUpdateKind value, JsonSerializerOptions options) + => Throw.IfNull(writer).WriteStringValue(value.Value); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs index b3edbad5e99..cf282e1a011 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs @@ -1,9 +1,12 @@ -// 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.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -38,6 +41,70 @@ public class UsageDetails /// public long? ReasoningTokenCount { get; set; } + /// Gets or sets the number of audio input tokens used. + /// + /// Audio input tokens should be counted as part of . + /// + [Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] + [JsonIgnore] + public long? InputAudioTokenCount + { + get => InputAudioTokenCountCore; + set => InputAudioTokenCountCore = value; + } + + [JsonInclude] + [JsonPropertyName("inputAudioTokenCount")] + internal long? InputAudioTokenCountCore { get; set; } + + /// Gets or sets the number of text input tokens used. + /// + /// Text input tokens should be counted as part of . + /// + [Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] + [JsonIgnore] + public long? InputTextTokenCount + { + get => InputTextTokenCountCore; + set => InputTextTokenCountCore = value; + } + + [JsonInclude] + [JsonPropertyName("inputTextTokenCount")] + internal long? InputTextTokenCountCore { get; set; } + + /// Gets or sets the number of audio output tokens used. + /// + /// Audio output tokens should be counted as part of . + /// + [Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] + [JsonIgnore] + public long? OutputAudioTokenCount + { + get => OutputAudioTokenCountCore; + set => OutputAudioTokenCountCore = value; + } + + [JsonInclude] + [JsonPropertyName("outputAudioTokenCount")] + internal long? OutputAudioTokenCountCore { get; set; } + + /// Gets or sets the number of text output tokens used. + /// + /// Text output tokens should be counted as part of . + /// + [Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] + [JsonIgnore] + public long? OutputTextTokenCount + { + get => OutputTextTokenCountCore; + set => OutputTextTokenCountCore = value; + } + + [JsonInclude] + [JsonPropertyName("outputTextTokenCount")] + internal long? OutputTextTokenCountCore { get; set; } + /// Gets or sets a dictionary of additional usage counts. /// /// All values set here are assumed to be summable. For example, when middleware makes multiple calls to an underlying @@ -57,6 +124,10 @@ public void Add(UsageDetails usage) TotalTokenCount = NullableSum(TotalTokenCount, usage.TotalTokenCount); CachedInputTokenCount = NullableSum(CachedInputTokenCount, usage.CachedInputTokenCount); ReasoningTokenCount = NullableSum(ReasoningTokenCount, usage.ReasoningTokenCount); + InputAudioTokenCount = NullableSum(InputAudioTokenCount, usage.InputAudioTokenCount); + InputTextTokenCount = NullableSum(InputTextTokenCount, usage.InputTextTokenCount); + OutputAudioTokenCount = NullableSum(OutputAudioTokenCount, usage.OutputAudioTokenCount); + OutputTextTokenCount = NullableSum(OutputTextTokenCount, usage.OutputTextTokenCount); if (usage.AdditionalCounts is { } countsToAdd) { @@ -109,6 +180,25 @@ internal string DebuggerDisplay parts.Add($"{nameof(ReasoningTokenCount)} = {reasoning}"); } + if (InputAudioTokenCount is { } inputAudio) + { + parts.Add($"{nameof(InputAudioTokenCount)} = {inputAudio}"); + } + + if (InputTextTokenCount is { } inputText) + { + parts.Add($"{nameof(InputTextTokenCount)} = {inputText}"); + } + + if (OutputAudioTokenCount is { } outputAudio) + { + parts.Add($"{nameof(OutputAudioTokenCount)} = {outputAudio}"); + } + + if (OutputTextTokenCount is { } outputText) + { + parts.Add($"{nameof(OutputTextTokenCount)} = {outputText}"); + } if (AdditionalCounts is { } additionalCounts) { foreach (var entry in additionalCounts) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs index 575d51aa3b2..6feffa455c0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -148,6 +148,13 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(SpeechToTextResponseUpdate))] [JsonSerializable(typeof(IReadOnlyList))] + // ITextToSpeechClient + [JsonSerializable(typeof(TextToSpeechOptions))] + [JsonSerializable(typeof(TextToSpeechClientMetadata))] + [JsonSerializable(typeof(TextToSpeechResponse))] + [JsonSerializable(typeof(TextToSpeechResponseUpdate))] + [JsonSerializable(typeof(IReadOnlyList))] + // IImageGenerator [JsonSerializable(typeof(ImageGenerationOptions))] [JsonSerializable(typeof(ImageGenerationResponse))] diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj index 8ee31bc2b1a..8a960fc4df1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj @@ -1,6 +1,6 @@  - - - - CP0001 - T:Microsoft.Extensions.AI.ChatClientBuilderToolReductionExtensions - lib/net10.0/Microsoft.Extensions.AI.dll - lib/net10.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.EmbeddingToolReductionStrategy - lib/net10.0/Microsoft.Extensions.AI.dll - lib/net10.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.ToolReducingChatClient - lib/net10.0/Microsoft.Extensions.AI.dll - lib/net10.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.ChatClientBuilderToolReductionExtensions - lib/net462/Microsoft.Extensions.AI.dll - lib/net462/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.EmbeddingToolReductionStrategy - lib/net462/Microsoft.Extensions.AI.dll - lib/net462/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.ToolReducingChatClient - lib/net462/Microsoft.Extensions.AI.dll - lib/net462/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.ChatClientBuilderToolReductionExtensions - lib/net8.0/Microsoft.Extensions.AI.dll - lib/net8.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.EmbeddingToolReductionStrategy - lib/net8.0/Microsoft.Extensions.AI.dll - lib/net8.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.ToolReducingChatClient - lib/net8.0/Microsoft.Extensions.AI.dll - lib/net8.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.ChatClientBuilderToolReductionExtensions - lib/net9.0/Microsoft.Extensions.AI.dll - lib/net9.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.EmbeddingToolReductionStrategy - lib/net9.0/Microsoft.Extensions.AI.dll - lib/net9.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.ToolReducingChatClient - lib/net9.0/Microsoft.Extensions.AI.dll - lib/net9.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.ChatClientBuilderToolReductionExtensions - lib/netstandard2.0/Microsoft.Extensions.AI.dll - lib/netstandard2.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.EmbeddingToolReductionStrategy - lib/netstandard2.0/Microsoft.Extensions.AI.dll - lib/netstandard2.0/Microsoft.Extensions.AI.dll - true - - - CP0001 - T:Microsoft.Extensions.AI.ToolReducingChatClient - lib/netstandard2.0/Microsoft.Extensions.AI.dll - lib/netstandard2.0/Microsoft.Extensions.AI.dll - true - - \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs index 9025b95dfc3..81a5eb4aa0a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -38,19 +38,21 @@ public sealed class OpenTelemetryEmbeddingGenerator : Delega private readonly string? _endpointAddress; private readonly int _endpointPort; + private readonly ILogger? _logger; + /// /// Initializes a new instance of the class. /// /// The underlying , which is the next stage of the pipeline. /// The to use for emitting any logging data from the generator. /// An optional source name that will be used on the telemetry data. -#pragma warning disable IDE0060 // Remove unused parameter; it exists for future use and consistency with OpenTelemetryChatClient public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator innerGenerator, ILogger? logger = null, string? sourceName = null) -#pragma warning restore IDE0060 : base(innerGenerator) { Debug.Assert(innerGenerator is not null, "Should have been validated by the base ctor."); + _logger = logger; + if (innerGenerator!.GetService() is EmbeddingGeneratorMetadata metadata) { _defaultModelId = metadata.DefaultModelId; @@ -235,6 +237,11 @@ private void TraceResponse( _ = activity .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) .SetStatus(ActivityStatusCode.Error, error.Message); + + if (_logger is not null) + { + OpenTelemetryLog.OperationException(_logger, error); + } } if (inputTokens.HasValue) diff --git a/src/Libraries/Microsoft.Extensions.AI/Files/OpenTelemetryHostedFileClient.cs b/src/Libraries/Microsoft.Extensions.AI/Files/OpenTelemetryHostedFileClient.cs index 9683c1bf5a4..ef8be1f2f44 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Files/OpenTelemetryHostedFileClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Files/OpenTelemetryHostedFileClient.cs @@ -58,17 +58,19 @@ public sealed class OpenTelemetryHostedFileClient : DelegatingHostedFileClient private readonly string? _serverAddress; private readonly int _serverPort; + private readonly ILogger? _logger; + /// Initializes a new instance of the class. /// The underlying . /// The to use for emitting any logging data from the client. /// An optional source name that will be used on the telemetry data. -#pragma warning disable IDE0060 // Remove unused parameter; it exists for consistency with OpenTelemetryChatClient and future use public OpenTelemetryHostedFileClient(IHostedFileClient innerClient, ILogger? logger = null, string? sourceName = null) -#pragma warning restore IDE0060 : base(innerClient) { Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); + _logger = logger; + if (innerClient!.GetService() is HostedFileClientMetadata metadata) { _providerName = metadata.ProviderName; @@ -390,13 +392,18 @@ public override async Task DeleteAsync( } } - private static void SetErrorStatus(Activity? activity, Exception? error) + private void SetErrorStatus(Activity? activity, Exception? error) { if (error is not null) { _ = activity? .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) .SetStatus(ActivityStatusCode.Error, error.Message); + + if (_logger is not null) + { + OpenTelemetryLog.OperationException(_logger, error); + } } } diff --git a/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs index 0f13f5ac1de..8ffbd0b9dec 100644 --- a/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs +++ b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs @@ -21,9 +21,14 @@ internal static class OpenTelemetryConsts public const string TypeText = "text"; public const string TypeJson = "json"; public const string TypeImage = "image"; + public const string TypeAudio = "audio"; public const string TokenTypeInput = "input"; public const string TokenTypeOutput = "output"; + public const string TokenTypeInputAudio = "input_audio"; + public const string TokenTypeInputText = "input_text"; + public const string TokenTypeOutputAudio = "output_audio"; + public const string TokenTypeOutputText = "output_text"; public static class Error { @@ -36,9 +41,17 @@ public static class GenAI public const string EmbeddingsName = "embeddings"; public const string ExecuteToolName = "execute_tool"; public const string InvokeAgentName = "invoke_agent"; + public const string InvokeWorkflowName = "invoke_workflow"; public const string OrchestrateToolsName = "orchestrate_tools"; // Non-standard public const string GenerateContentName = "generate_content"; + /// + /// Operation name for realtime sessions. + /// This is a custom extension not part of the OpenTelemetry GenAI semantic conventions. + /// The spec allows using custom values for gen_ai.operation.name when standard values don't apply. + /// + public const string RealtimeName = "realtime"; + public const string SystemInstructions = "gen_ai.system_instructions"; public static class Client @@ -153,6 +166,42 @@ public static class Usage public const string InputTokens = "gen_ai.usage.input_tokens"; public const string OutputTokens = "gen_ai.usage.output_tokens"; public const string CacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"; + public const string InputAudioTokens = "gen_ai.usage.input_audio_tokens"; + public const string InputTextTokens = "gen_ai.usage.input_text_tokens"; + public const string OutputAudioTokens = "gen_ai.usage.output_audio_tokens"; + public const string OutputTextTokens = "gen_ai.usage.output_text_tokens"; + } + + /// + /// Custom attributes for realtime sessions. + /// These attributes are NOT part of the OpenTelemetry GenAI semantic conventions (as of v1.40). + /// They are custom extensions to capture realtime session-specific configuration. + /// + public static class Realtime + { + /// + /// The voice used for audio output in a realtime session. + /// Custom attribute: "gen_ai.realtime.voice". + /// + public const string Voice = "gen_ai.realtime.voice"; + + /// + /// The output modalities configured for a realtime session (e.g., "Text", "Audio"). + /// Custom attribute: "gen_ai.realtime.output_modalities". + /// + public const string OutputModalities = "gen_ai.realtime.output_modalities"; + + /// + /// The kind/type of realtime session (e.g., "TextInTextOut", "AudioInAudioOut"). + /// Custom attribute: "gen_ai.realtime.session_kind". + /// + public const string SessionKind = "gen_ai.realtime.session_kind"; + + /// + /// The modalities actually received in a realtime response (e.g., "text", "audio", "transcription"). + /// Custom attribute: "gen_ai.realtime.received_modalities". + /// + public const string ReceivedModalities = "gen_ai.realtime.received_modalities"; } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClient.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClient.cs new file mode 100644 index 00000000000..0510965c8be --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClient.cs @@ -0,0 +1,131 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating realtime client that invokes functions defined on . +/// Include this in a realtime client pipeline to resolve function calls automatically. +/// +/// +/// +/// When sessions created by this client receive a in a realtime server message from the inner +/// , they respond by invoking the corresponding defined +/// in (or in ), producing a +/// that is sent back to the inner session. This loop is repeated until there are no more function calls to make, or until +/// another stop condition is met, such as hitting . +/// +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class FunctionInvokingRealtimeClient : DelegatingRealtimeClient +{ + private readonly ILoggerFactory? _loggerFactory; + private readonly IServiceProvider? _services; + + /// + /// Initializes a new instance of the class. + /// + /// The inner . + /// An to use for logging information about function invocation. + /// An optional to use for resolving services required by the instances being invoked. + public FunctionInvokingRealtimeClient(IRealtimeClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) + : base(innerClient) + { + _loggerFactory = loggerFactory; + _services = functionInvocationServices; + } + + /// + /// Gets the for the current function invocation. + /// + /// + /// This value flows across async calls. + /// + public static FunctionInvocationContext? CurrentContext => FunctionInvokingRealtimeClientSession.CurrentContext; + + /// + /// Gets or sets a value indicating whether detailed exception information should be included + /// in the response when calling the underlying . + /// + /// + /// if the full exception message is added to the response. + /// if a generic error message is included in the response. + /// The default value is . + /// + public bool IncludeDetailedErrors { get; set; } + + /// + /// Gets or sets a value indicating whether to allow concurrent invocation of functions. + /// + /// + /// if multiple function calls can execute in parallel. + /// if function calls are processed serially. + /// The default value is . + /// + public bool AllowConcurrentInvocation { get; set; } + + /// + /// Gets or sets the maximum number of iterations per request. + /// + /// + /// The maximum number of iterations per request. + /// The default value is 40. + /// + public int MaximumIterationsPerRequest + { + get; + set + { + if (value < 1) + { + Throw.ArgumentOutOfRangeException(nameof(value)); + } + + field = value; + } + } = 40; + + /// + /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error. + /// + /// + /// The maximum number of consecutive iterations that are allowed to fail with an error. + /// The default value is 3. + /// + public int MaximumConsecutiveErrorsPerRequest + { + get; + set => field = Throw.IfLessThan(value, 0); + } = 3; + + /// Gets or sets a collection of additional tools the session is able to invoke. + public IList? AdditionalTools { get; set; } + + /// Gets or sets a value indicating whether a request to call an unknown function should terminate the function calling loop. + /// + /// to terminate the function calling loop and return the response if a request to call a tool + /// that isn't available is received; to create and send a + /// function result message stating that the tool couldn't be found. The default is . + /// + public bool TerminateOnUnknownCalls { get; set; } + + /// Gets or sets a delegate used to invoke instances. + public Func>? FunctionInvoker { get; set; } + + /// + public override async Task CreateSessionAsync( + RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default) + { + var innerSession = await base.CreateSessionAsync(options, cancellationToken).ConfigureAwait(false); + return new FunctionInvokingRealtimeClientSession(innerSession, this, _loggerFactory, _services); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClientBuilderExtensions.cs new file mode 100644 index 00000000000..06c90d7a104 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClientBuilderExtensions.cs @@ -0,0 +1,43 @@ +// 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.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods for attaching function invocation middleware to a realtime client pipeline. +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public static class FunctionInvokingRealtimeClientBuilderExtensions +{ + /// + /// Enables automatic function call invocation on the realtime client pipeline. + /// + /// The being used to build the realtime client pipeline. + /// An optional to use to create a logger for logging function invocations. + /// An optional callback that can be used to configure the instance. + /// The supplied . + /// is . + public static RealtimeClientBuilder UseFunctionInvocation( + this RealtimeClientBuilder builder, + ILoggerFactory? loggerFactory = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + { + loggerFactory ??= services.GetService(); + + var client = new FunctionInvokingRealtimeClient(innerClient, loggerFactory, services); + configure?.Invoke(client); + return client; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClientSession.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClientSession.cs new file mode 100644 index 00000000000..7c79bc449fb --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/FunctionInvokingRealtimeClientSession.cs @@ -0,0 +1,415 @@ +// 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; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.Diagnostics; + +using FunctionInvocationResult = Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationResult; +using FunctionInvocationStatus = Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus; + +#pragma warning disable CA2213 // Disposable fields should be disposed +#pragma warning disable S2219 // Runtime type checking should be simplified +#pragma warning disable S3353 // Unchanged local variables should be "const" + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating realtime session that invokes functions defined on . +/// Include this in a realtime session pipeline to resolve function calls automatically. +/// +/// +/// +/// When this session receives a in a realtime server message from its inner +/// , it responds by invoking the corresponding defined +/// in (or in ), producing a +/// that it sends back to the inner session. This loop is repeated until there are no more function calls to make, or until +/// another stop condition is met, such as hitting . +/// +/// +/// If a requested function is an but not an , the +/// will not attempt to invoke it, and instead allow that +/// to pass back out to the caller. It is then that caller's responsibility to create the appropriate +/// for that call and send it back as part of a subsequent request. +/// +/// +/// A instance is thread-safe for concurrent use so long as the +/// instances employed as part of the supplied are also safe. +/// The property can be used to control whether multiple function invocation +/// requests as part of the same request are invocable concurrently, but even with that set to +/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those +/// tools being used concurrently (one per request). +/// +/// +/// Known limitation: Function invocation blocks the message processing loop. While functions are being +/// invoked, incoming server messages (including user interruptions) are buffered and not processed until the +/// invocation completes. +/// +/// +internal sealed class FunctionInvokingRealtimeClientSession : IRealtimeClientSession +{ + /// The for the current function invocation. + private static readonly AsyncLocal _currentContext = new(); + + /// Gets the specified when constructing the , if any. + private IServiceProvider? FunctionInvocationServices { get; } + + /// The logger to use for logging information about function invocation. + private readonly ILogger _logger; + + /// The to use for telemetry. + /// This component does not own the instance and should not dispose it. + private readonly ActivitySource? _activitySource; + + /// The inner session to delegate to. + private readonly IRealtimeClientSession _innerSession; + + /// The owning client that holds configuration. + private readonly FunctionInvokingRealtimeClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying , or the next instance in a chain of sessions. + /// The owning that holds configuration. + /// An to use for logging information about function invocation. + /// An optional to use for resolving services required by the instances being invoked. + public FunctionInvokingRealtimeClientSession(IRealtimeClientSession innerSession, FunctionInvokingRealtimeClient client, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) + { + _innerSession = Throw.IfNull(innerSession); + _client = Throw.IfNull(client); + _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; + _activitySource = innerSession.GetService(); + FunctionInvocationServices = functionInvocationServices; + } + + /// Gets the function invocation processor, creating it lazily. + private FunctionInvocationProcessor Processor => field ??= new FunctionInvocationProcessor( + _logger, + _activitySource, + InvokeFunctionAsync); + + /// + /// Gets or sets the for the current function invocation. + /// + /// + /// This value flows across async calls. + /// + internal static FunctionInvocationContext? CurrentContext + { + get => _currentContext.Value; + set => _currentContext.Value = value; + } + + private bool IncludeDetailedErrors => _client.IncludeDetailedErrors; + + private bool AllowConcurrentInvocation => _client.AllowConcurrentInvocation; + + private int MaximumIterationsPerRequest => _client.MaximumIterationsPerRequest; + + private int MaximumConsecutiveErrorsPerRequest => _client.MaximumConsecutiveErrorsPerRequest; + + private IList? AdditionalTools => _client.AdditionalTools; + + private bool TerminateOnUnknownCalls => _client.TerminateOnUnknownCalls; + + private Func>? FunctionInvoker => _client.FunctionInvoker; + + /// + public RealtimeSessionOptions? Options => _innerSession.Options; + + /// + public Task SendAsync(RealtimeClientMessage message, CancellationToken cancellationToken = default) => + _innerSession.SendAsync(message, cancellationToken); + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + _innerSession.GetService(serviceType, serviceKey); + } + + /// + public async ValueTask DisposeAsync() + { + await _innerSession.DisposeAsync().ConfigureAwait(false); + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Create an activity to group function invocations together for better observability. + using Activity? activity = FunctionInvocationHelpers.CurrentActivityIsInvokeAgent ? null : _activitySource?.StartActivity(OpenTelemetryConsts.GenAI.OrchestrateToolsName); + + // Track function calls from the client messages + List? functionCallContents = null; + int consecutiveErrorCount = 0; + int iterationCount = 0; + + await foreach (var message in _innerSession.GetStreamingResponseAsync(cancellationToken).ConfigureAwait(false)) + { + // Check if this message contains function calls + bool hasFunctionCalls = false; + if (message is ResponseOutputItemRealtimeServerMessage responseOutputItemMessage && responseOutputItemMessage.Type == RealtimeServerMessageType.ResponseOutputItemDone) + { + // Extract function calls from the message + functionCallContents ??= []; + hasFunctionCalls = ExtractFunctionCalls(responseOutputItemMessage, functionCallContents); + } + + // Always yield the message so consumers can observe function calls and other events. + yield return message; + + if (hasFunctionCalls) + { + if (iterationCount >= MaximumIterationsPerRequest) + { + // Log and stop processing function calls + FunctionInvocationLogger.LogMaximumIterationsReached(_logger, MaximumIterationsPerRequest); + continue; + } + + // Check whether the function calls can be handled; if not, terminate the loop. + if (ShouldTerminateBasedOnFunctionCalls(functionCallContents!)) + { + yield break; + } + + // Process function calls + iterationCount++; + var results = await InvokeFunctionsAsync(functionCallContents!, consecutiveErrorCount, cancellationToken).ConfigureAwait(false); + + // Update consecutive error count + consecutiveErrorCount = results.newConsecutiveErrorCount; + + // Check if we should terminate + if (results.shouldTerminate) + { + yield break; + } + + foreach (var resultMessage in results.functionResults) + { + // inject back the function result messages to the inner session + await _innerSession.SendAsync(resultMessage, cancellationToken).ConfigureAwait(false); + } + } + } + } + + /// Extracts function calls from a realtime server message. + private static bool ExtractFunctionCalls(ResponseOutputItemRealtimeServerMessage message, List functionCallContents) + { + if (message.Item is null) + { + return false; + } + + functionCallContents.Clear(); + + foreach (var content in message.Item.Contents) + { + if (content is FunctionCallContent functionCallContent) + { + functionCallContents.Add(functionCallContent); + } + } + + return functionCallContents.Count > 0; + } + + /// Finds a tool by name in the specified tool lists. + private static AIFunctionDeclaration? FindTool(string name, params ReadOnlySpan?> toolLists) + { + foreach (var toolList in toolLists) + { + if (toolList is not null) + { + foreach (AITool tool in toolList) + { + if (tool is AIFunctionDeclaration declaration && string.Equals(tool.Name, name, StringComparison.Ordinal)) + { + return declaration; + } + } + } + } + + return null; + } + + /// Checks whether there are any tools in the specified tool lists. + private static bool HasAnyTools(params ReadOnlySpan?> toolLists) + { + foreach (var toolList in toolLists) + { + if (toolList is not null) + { + using var enumerator = toolList.GetEnumerator(); + if (enumerator.MoveNext()) + { + return true; + } + } + } + + return false; + } + + /// Gets whether the function calling loop should exit based on the function call requests. + /// + /// This mirrors the logic in FunctionInvokingChatClient.ShouldTerminateLoopBasedOnHandleableFunctions. + /// If a function call references a non-invocable tool (a declaration but not an ), + /// the loop always terminates. If the function is completely unknown, the loop terminates only when + /// is . + /// + private bool ShouldTerminateBasedOnFunctionCalls(List functionCallContents) + { + if (!HasAnyTools(AdditionalTools, _innerSession.Options?.Tools)) + { + // No tools available at all. If TerminateOnUnknownCalls, stop the loop. + if (TerminateOnUnknownCalls) + { + foreach (var fcc in functionCallContents) + { + FunctionInvocationLogger.LogFunctionNotFound(_logger, fcc.Name); + } + + return true; + } + + return false; + } + + foreach (var fcc in functionCallContents) + { + AIFunctionDeclaration? tool = FindTool(fcc.Name, AdditionalTools, _innerSession.Options?.Tools); + if (tool is not null) + { + if (tool is not AIFunction) + { + // The tool exists but is not invocable (e.g. AIFunctionDeclaration only). + // Always terminate so the caller can handle the call. + FunctionInvocationLogger.LogNonInvocableFunction(_logger, fcc.Name); + return true; + } + } + else if (TerminateOnUnknownCalls) + { + // The tool is completely unknown. If configured, terminate. + FunctionInvocationLogger.LogFunctionNotFound(_logger, fcc.Name); + return true; + } + } + + return false; + } + + /// Invokes the functions and returns results. + private async Task<(bool shouldTerminate, int newConsecutiveErrorCount, List functionResults)> InvokeFunctionsAsync( + List functionCallContents, + int consecutiveErrorCount, + CancellationToken cancellationToken) + { + var captureCurrentIterationExceptions = consecutiveErrorCount < MaximumConsecutiveErrorsPerRequest; + + // Use the processor to handle function calls + var results = await Processor.ProcessFunctionCallsAsync( + functionCallContents, + name => FindTool(name, AdditionalTools, _innerSession.Options?.Tools), + AllowConcurrentInvocation, + (callContent, aiFunction, _) => new FunctionInvocationContext + { + Function = aiFunction, + Arguments = new(callContent.Arguments) { Services = FunctionInvocationServices }, + CallContent = callContent + }, + ctx => CurrentContext = ctx, + captureCurrentIterationExceptions, + cancellationToken).ConfigureAwait(false); + + var shouldTerminate = results.Exists(static r => r.Terminate); + + // Update consecutive error count + bool hasErrors = results.Exists(static r => r.Status == FunctionInvocationStatus.Exception); + int newConsecutiveErrorCount = hasErrors ? consecutiveErrorCount + 1 : 0; + + // Check if we exceeded the maximum consecutive errors + if (newConsecutiveErrorCount > MaximumConsecutiveErrorsPerRequest) + { + var firstException = results.Find(static r => r.Exception is not null)?.Exception; + if (firstException is not null) + { + throw firstException; + } + } + + // Create function result messages + var functionResults = CreateFunctionResultMessages(results); + + return (shouldTerminate, newConsecutiveErrorCount, functionResults); + } + + /// Creates function result messages from invocation results. + private List CreateFunctionResultMessages(List results) + { + var messages = new List(results.Count); + + foreach (var result in results) + { + // Determine the result value to send back + object? resultValue = result.Status switch + { + FunctionInvocationStatus.RanToCompletion => result.Result, + FunctionInvocationStatus.NotFound => "Error: Function not found.", + FunctionInvocationStatus.Exception => IncludeDetailedErrors && result.Exception is not null + ? $"Error: {result.Exception.Message}" + : "Error: Function invocation failed.", + _ => "Error: Unknown status." + }; + + // Create the FunctionResultContent + var functionResultContent = new FunctionResultContent(result.CallContent.CallId, resultValue) + { + Exception = result.Exception + }; + + // Create the RealtimeConversationItem with the function result + var contentItem = new RealtimeConversationItem([functionResultContent]); + + // Create the conversation item create message + var message = new CreateConversationItemRealtimeClientMessage(contentItem); + messages.Add(message); + } + + // Add a response create message so the model responds to the function results. + // Do not hardcode output modalities; let the session defaults apply so audio sessions + // continue to work correctly. + messages.Add(new CreateResponseRealtimeClientMessage()); + + return messages; + } + + /// This method will invoke the function within the try block. + /// The function invocation context. + /// Cancellation token. + /// The function result. + private ValueTask InvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) + { + _ = Throw.IfNull(context); + + return FunctionInvoker is { } invoker ? + invoker(context, cancellationToken) : + context.Function.InvokeAsync(context.Arguments, cancellationToken); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClient.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClient.cs new file mode 100644 index 00000000000..e1b1bb92915 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClient.cs @@ -0,0 +1,56 @@ +// 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; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating realtime client that logs operations to an . +/// +/// +/// When the employed enables , the contents of +/// messages and options are logged. These messages and options may contain sensitive application data. +/// is disabled by default and should never be enabled in a production environment. +/// Messages and options are not logged at other logging levels. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class LoggingRealtimeClient : DelegatingRealtimeClient +{ + private readonly ILogger _logger; + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The inner . + /// An instance that will be used for all logging. + public LoggingRealtimeClient(IRealtimeClient innerClient, ILogger logger) + : base(innerClient) + { + _logger = Throw.IfNull(logger); + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; + } + + /// Gets or sets JSON serialization options to use when serializing logging data. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public override async Task CreateSessionAsync( + RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default) + { + var innerSession = await base.CreateSessionAsync(options, cancellationToken).ConfigureAwait(false); + return new LoggingRealtimeClientSession(innerSession, _logger) + { + JsonSerializerOptions = _jsonSerializerOptions, + }; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClientBuilderExtensions.cs new file mode 100644 index 00000000000..a3aedf33b4c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClientBuilderExtensions.cs @@ -0,0 +1,59 @@ +// 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.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring logging on an pipeline. +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public static class LoggingRealtimeClientBuilderExtensions +{ + /// Adds logging to the realtime client pipeline. + /// The . + /// + /// An optional used to create a logger with which logging should be performed. + /// 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 . + /// + /// + /// When the employed enables , the contents of + /// messages and options are logged. These messages and options may contain sensitive application data. + /// is disabled by default and should never be enabled in a production environment. + /// Messages and options are not logged at other logging levels. + /// + /// + public static RealtimeClientBuilder UseLogging( + this RealtimeClientBuilder builder, + ILoggerFactory? loggerFactory = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + { + loggerFactory ??= services.GetRequiredService(); + + // If the factory we resolve is for the null logger, the LoggingRealtimeClient will end up + // being an expensive nop, so skip adding it and just return the inner client. + if (loggerFactory == NullLoggerFactory.Instance) + { + return innerClient; + } + + var logger = loggerFactory.CreateLogger(typeof(LoggingRealtimeClient)); + var client = new LoggingRealtimeClient(innerClient, logger); + configure?.Invoke(client); + return client; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClientSession.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClientSession.cs new file mode 100644 index 00000000000..e1cf3b88a97 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/LoggingRealtimeClientSession.cs @@ -0,0 +1,261 @@ +// 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.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating realtime session that logs operations to an . +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// employed is also thread-safe for concurrent use. +/// +/// +/// When the employed enables , the contents of +/// messages and options are logged. These messages and options may contain sensitive application data. +/// is disabled by default and should never be enabled in a production environment. +/// Messages and options are not logged at other logging levels. +/// +/// +internal sealed partial class LoggingRealtimeClientSession : IRealtimeClientSession +{ + /// An instance used for all logging. + private readonly ILogger _logger; + + /// The inner session to delegate to. + private readonly IRealtimeClientSession _innerSession; + + /// The to use for serialization of state written to the logger. + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used for all logging. + public LoggingRealtimeClientSession(IRealtimeClientSession innerSession, ILogger logger) + { + _innerSession = Throw.IfNull(innerSession); + _logger = Throw.IfNull(logger); + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; + } + + /// + public RealtimeSessionOptions? Options => _innerSession.Options; + + /// Gets or sets JSON serialization options to use when serializing logging data. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public async ValueTask DisposeAsync() + { + await _innerSession.DisposeAsync().ConfigureAwait(false); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + _innerSession.GetService(serviceType, serviceKey); + } + + /// + public async Task SendAsync(RealtimeClientMessage message, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(message); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogSendMessageSensitive(GetLoggableString(message)); + } + else + { + LogSendMessage(); + } + } + + try + { + await _innerSession.SendAsync(message, cancellationToken).ConfigureAwait(false); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + LogCompleted(nameof(SendAsync)); + } + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(SendAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(SendAsync), ex); + throw; + } + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + LogInvoked(nameof(GetStreamingResponseAsync)); + } + + IAsyncEnumerator e; + try + { + e = _innerSession.GetStreamingResponseAsync(cancellationToken).GetAsyncEnumerator(cancellationToken); + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GetStreamingResponseAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GetStreamingResponseAsync), ex); + throw; + } + + try + { + RealtimeServerMessage? message = null; + while (true) + { + try + { + if (!await e.MoveNextAsync().ConfigureAwait(false)) + { + break; + } + + message = e.Current; + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GetStreamingResponseAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GetStreamingResponseAsync), ex); + throw; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogStreamingServerMessageSensitive(GetLoggableString(message)); + } + else + { + LogStreamingServerMessage(); + } + } + + yield return message; + } + + LogCompleted(nameof(GetStreamingResponseAsync)); + } + finally + { + await e.DisposeAsync().ConfigureAwait(false); + } + } + + private string GetLoggableString(RealtimeClientMessage message) + { + var obj = new JsonObject + { + ["type"] = message.GetType().Name, + }; + + if (message.RawRepresentation is string s) + { + obj["content"] = s; + } + else if (message.RawRepresentation is not null) + { + obj["content"] = AsJson(message.RawRepresentation); + } + else if (message.MessageId is not null) + { + obj["messageId"] = message.MessageId; + } + + return obj.ToJsonString(); + } + + private string GetLoggableString(RealtimeServerMessage message) + { + var obj = new JsonObject + { + ["type"] = message.Type.ToString(), + }; + + if (message.RawRepresentation is string s) + { + obj["content"] = s; + } + else if (message.RawRepresentation is not null) + { + obj["content"] = AsJson(message.RawRepresentation); + } + else if (message.MessageId is not null) + { + obj["messageId"] = message.MessageId; + } + + return obj.ToJsonString(); + } + + private string AsJson(T value) => TelemetryHelpers.AsJson(value, _jsonSerializerOptions); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] + private partial void LogInvoked(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: Options: {Options}.")] + private partial void LogInvokedSensitive(string methodName, string options); + + [LoggerMessage(LogLevel.Debug, "SendAsync invoked.")] + private partial void LogSendMessage(); + + [LoggerMessage(LogLevel.Trace, "SendAsync invoked: Message: {Message}.")] + private partial void LogSendMessageSensitive(string message); + + [LoggerMessage(LogLevel.Debug, "{MethodName} completed.")] + private partial void LogCompleted(string methodName); + + [LoggerMessage(LogLevel.Debug, "GetStreamingResponseAsync received server message.")] + private partial void LogStreamingServerMessage(); + + [LoggerMessage(LogLevel.Trace, "GetStreamingResponseAsync received server message: {ServerMessage}")] + private partial void LogStreamingServerMessageSensitive(string serverMessage); + + [LoggerMessage(LogLevel.Debug, "{MethodName} canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClient.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClient.cs new file mode 100644 index 00000000000..a0e4510d5c4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClient.cs @@ -0,0 +1,71 @@ +// 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; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating realtime client that adds OpenTelemetry support, following the OpenTelemetry Semantic Conventions for Generative AI systems. +/// +/// +/// +/// The draft specification this follows is available at . +/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class OpenTelemetryRealtimeClient : DelegatingRealtimeClient +{ + private readonly ILogger? _logger; + private readonly string? _sourceName; + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The inner . + /// The to use for emitting any logging data from the client. + /// An optional source name that will be used on the telemetry data. + public OpenTelemetryRealtimeClient(IRealtimeClient innerClient, ILogger? logger = null, string? sourceName = null) + : base(innerClient) + { + _logger = logger; + _sourceName = sourceName; + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; + } + + /// + /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. + /// + /// + /// if potentially sensitive information should be included in telemetry; + /// if telemetry shouldn't include raw inputs and outputs. + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). + /// + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; + + /// Gets or sets JSON serialization options to use when formatting realtime data into telemetry strings. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public override async Task CreateSessionAsync( + RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default) + { + var innerSession = await base.CreateSessionAsync(options, cancellationToken).ConfigureAwait(false); + return new OpenTelemetryRealtimeClientSession(innerSession, _logger, _sourceName) + { + EnableSensitiveData = EnableSensitiveData, + JsonSerializerOptions = _jsonSerializerOptions, + }; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClientBuilderExtensions.cs new file mode 100644 index 00000000000..cd17b03bfb2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClientBuilderExtensions.cs @@ -0,0 +1,79 @@ +// 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.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring OpenTelemetry on an pipeline. +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public static class OpenTelemetryRealtimeClientBuilderExtensions +{ + /// + /// Adds OpenTelemetry support to the realtime client pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// + /// + /// The draft specification this follows is available at . + /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. + /// + /// + /// The following standard OpenTelemetry GenAI conventions are supported: + /// + /// gen_ai.operation.name - Operation name ("realtime") + /// gen_ai.request.model - Model name from options + /// gen_ai.provider.name - Provider name from metadata + /// gen_ai.response.id - Response ID from ResponseDone messages + /// gen_ai.usage.input_tokens - Input token count + /// gen_ai.usage.output_tokens - Output token count + /// gen_ai.request.max_tokens - Max output tokens from options + /// gen_ai.system_instructions - Instructions from options (sensitive data) + /// gen_ai.conversation.id - Conversation ID from response + /// gen_ai.tool.definitions - Tool definitions (sensitive data) + /// server.address / server.port - Server endpoint info + /// error.type - Error type on failures + /// + /// + /// + /// Additionally, the following realtime-specific custom attributes are supported: + /// + /// gen_ai.realtime.voice - Voice setting from options + /// gen_ai.realtime.output_modalities - Output modalities (text, audio) + /// gen_ai.realtime.voice_speed - Voice speed setting + /// gen_ai.realtime.session_kind - Session kind (Realtime/Transcription) + /// + /// + /// + /// Metrics include: + /// + /// gen_ai.client.operation.duration - Duration histogram + /// gen_ai.client.token.usage - Token usage histogram + /// + /// + /// + /// The . + /// An optional to use to create a logger for logging events. + /// An optional source name that will be used on the telemetry data. + /// An optional callback that can be used to configure the instance. + /// The . + /// is . + public static RealtimeClientBuilder UseOpenTelemetry( + this RealtimeClientBuilder builder, + ILoggerFactory? loggerFactory = null, + string? sourceName = null, + Action? configure = null) => + Throw.IfNull(builder).Use((innerClient, services) => + { + loggerFactory ??= services.GetService(); + + var logger = loggerFactory?.CreateLogger(typeof(OpenTelemetryRealtimeClient)); + var client = new OpenTelemetryRealtimeClient(innerClient, logger, sourceName); + configure?.Invoke(client); + return client; + }); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClientSession.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClientSession.cs new file mode 100644 index 00000000000..bc16075e9da --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/OpenTelemetryRealtimeClientSession.cs @@ -0,0 +1,1050 @@ +// 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; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable CA1308 // Normalize strings to uppercase +#pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter +#pragma warning disable SA1113 // Comma should be on the same line as previous parameter +#pragma warning disable SA1204 // Static members should appear before non-static members + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating realtime session that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// +/// +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at . +/// The specification is still experimental and subject to change; as such, the telemetry output by this session is also subject to change. +/// +/// +/// The following standard OpenTelemetry GenAI conventions are supported: +/// +/// gen_ai.operation.name - Operation name ("chat") +/// gen_ai.request.model - Model name from options +/// gen_ai.provider.name - Provider name from metadata +/// gen_ai.response.id - Response ID from ResponseDone messages +/// gen_ai.response.model - Model ID from response +/// gen_ai.usage.input_tokens - Input token count +/// gen_ai.usage.output_tokens - Output token count +/// gen_ai.request.max_tokens - Max output tokens from options +/// gen_ai.system_instructions - Instructions from options (sensitive data) +/// gen_ai.conversation.id - Conversation ID from response +/// gen_ai.tool.definitions - Tool definitions (sensitive data) +/// gen_ai.input.messages - Input tool/MCP messages (sensitive data) +/// gen_ai.output.messages - Output tool/MCP messages (sensitive data) +/// server.address / server.port - Server endpoint info +/// error.type - Error type on failures +/// +/// +/// +/// MCP (Model Context Protocol) semantic conventions are supported for tool calls and responses, including: +/// +/// MCP server tool calls and results +/// MCP approval requests and responses +/// Function calls and results +/// +/// +/// +/// Additionally, the following custom attributes are supported (not part of OpenTelemetry GenAI semantic conventions as of v1.40): +/// +/// gen_ai.request.tool_choice - Tool choice mode ("none", "auto", "required") or specific tool name +/// gen_ai.realtime.voice - Voice setting from options +/// gen_ai.realtime.output_modalities - Output modalities (text, audio) +/// gen_ai.realtime.voice_speed - Voice speed setting +/// gen_ai.realtime.session_kind - Session kind (Realtime/Transcription) +/// +/// +/// +/// Metrics include: +/// +/// gen_ai.client.operation.duration - Duration histogram +/// gen_ai.client.token.usage - Token usage histogram +/// +/// +/// +internal sealed partial class OpenTelemetryRealtimeClientSession : IRealtimeClientSession +{ + private readonly ActivitySource _activitySource; + private readonly Meter _meter; + + private readonly Histogram _tokenUsageHistogram; + private readonly Histogram _operationDurationHistogram; + + private readonly string? _defaultModelId; + private readonly string? _providerName; + private readonly string? _serverAddress; + private readonly int _serverPort; + + private readonly IRealtimeClientSession _innerSession; + private readonly ILogger? _logger; + + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// The to use for emitting any logging data from the session. + /// An optional source name that will be used on the telemetry data. + public OpenTelemetryRealtimeClientSession(IRealtimeClientSession innerSession, ILogger? logger = null, string? sourceName = null) + { + _innerSession = Throw.IfNull(innerSession); + _logger = logger; + + // Try to get metadata from the inner session's ChatClientMetadata if available + if (innerSession.GetService(typeof(ChatClientMetadata)) is ChatClientMetadata metadata) + { + _defaultModelId = metadata.DefaultModelId; + _providerName = metadata.ProviderName; + _serverAddress = metadata.ProviderUri?.Host; + _serverPort = metadata.ProviderUri?.Port ?? 0; + } + + string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + _activitySource = new(name); + _meter = new(name); + + _tokenUsageHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, + OpenTelemetryConsts.TokensUnit, + 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, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } + ); + + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; + } + + /// Gets or sets JSON serialization options to use when formatting realtime data into telemetry strings. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public RealtimeSessionOptions? Options => _innerSession.Options; + + /// + public async ValueTask DisposeAsync() + { + _activitySource.Dispose(); + _meter.Dispose(); + await _innerSession.DisposeAsync().ConfigureAwait(false); + } + + /// + /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. + /// + /// + /// if potentially sensitive information should be included in telemetry; + /// if telemetry shouldn't include raw inputs and outputs. + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). + /// + /// + /// By default, telemetry includes metadata, such as token counts, but not raw inputs + /// and outputs, such as message content, function call arguments, and function call results. + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. + /// + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceType == typeof(ActivitySource) ? _activitySource : + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + _innerSession.GetService(serviceType, serviceKey); + } + + /// + public async Task SendAsync(RealtimeClientMessage message, CancellationToken cancellationToken = default) + { + if (EnableSensitiveData && _activitySource.HasListeners()) + { + var otelMessage = ExtractClientOtelMessage(message); + + if (otelMessage is not null) + { + using Activity? inputActivity = CreateAndConfigureActivity(options: null); + if (inputActivity is { IsAllDataRequested: true }) + { + _ = inputActivity.AddTag(OpenTelemetryConsts.GenAI.Input.Messages, SerializeMessage(otelMessage)); + } + } + } + + await _innerSession.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _jsonSerializerOptions.MakeReadOnly(); + + RealtimeSessionOptions? options = Options; + string? requestModelId = options?.Model ?? _defaultModelId; + + // Start timing from the beginning of the streaming operation + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + + // Determine if we should capture messages for telemetry + bool captureMessages = EnableSensitiveData && _activitySource.HasListeners(); + + IAsyncEnumerable responses; + try + { + responses = _innerSession.GetStreamingResponseAsync(cancellationToken); + } + catch (Exception ex) + { + // Create an activity for the error case + using Activity? errorActivity = CreateAndConfigureActivity(options); + TraceStreamingResponse(errorActivity, requestModelId, response: null, ex, stopwatch); + throw; + } + + var responseEnumerator = responses.GetAsyncEnumerator(cancellationToken); + Exception? error = null; + List? outputMessages = captureMessages ? [] : null; + HashSet? outputModalities = _activitySource.HasListeners() ? [] : null; + try + { + while (true) + { + RealtimeServerMessage message; + try + { + if (!await responseEnumerator.MoveNextAsync().ConfigureAwait(false)) + { + break; + } + + message = responseEnumerator.Current; + } + catch (Exception ex) + { + error = ex; + throw; + } + + // Track output modalities + if (outputModalities is not null) + { + var modality = GetOutputModality(message); + if (modality is not null) + { + _ = outputModalities.Add(modality); + } + } + + // Capture output content from all server message types + if (outputMessages is not null) + { + var otelMessage = ExtractServerOtelMessage(message); + if (otelMessage is not null) + { + outputMessages.Add(otelMessage); + } + } + + // Create activity for ResponseDone message for telemetry + if (message is ResponseCreatedRealtimeServerMessage responseDoneMsg && + responseDoneMsg.Type == RealtimeServerMessageType.ResponseDone) + { + using Activity? responseActivity = CreateAndConfigureActivity(options); + + // Add output modalities and messages tags + AddOutputModalitiesTag(responseActivity, outputModalities); + AddOutputMessagesTag(responseActivity, outputMessages); + TraceStreamingResponse(responseActivity, requestModelId, responseDoneMsg, error, stopwatch); + } + + yield return message; + } + } + finally + { + // Trace error if an exception was thrown during streaming + if (error is not null) + { + using Activity? errorActivity = CreateAndConfigureActivity(options); + AddOutputModalitiesTag(errorActivity, outputModalities); + AddOutputMessagesTag(errorActivity, outputMessages); + TraceStreamingResponse(errorActivity, requestModelId, response: null, error, stopwatch); + } + + await responseEnumerator.DisposeAsync().ConfigureAwait(false); + } + } + + /// Adds output modalities tag to the activity. + private static void AddOutputModalitiesTag(Activity? activity, HashSet? outputModalities) + { + if (activity is { IsAllDataRequested: true } && outputModalities is { Count: > 0 }) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Realtime.ReceivedModalities, $"[{string.Join(", ", outputModalities.Select(m => $"\"{m}\""))}]"); + } + } + + /// Adds output messages tag to the activity if there are messages to add. + private static void AddOutputMessagesTag(Activity? activity, List? outputMessages) + { + if (activity is { IsAllDataRequested: true } && outputMessages is { Count: > 0 }) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Output.Messages, SerializeMessages(outputMessages)); + } + } + + /// Gets the output modality from a server message, if applicable. + private static string? GetOutputModality(RealtimeServerMessage message) + { + if (message is OutputTextAudioRealtimeServerMessage textAudio) + { + if (textAudio.Type == RealtimeServerMessageType.OutputTextDelta || textAudio.Type == RealtimeServerMessageType.OutputTextDone) + { + return "text"; + } + + if (textAudio.Type == RealtimeServerMessageType.OutputAudioDelta || textAudio.Type == RealtimeServerMessageType.OutputAudioDone) + { + return "audio"; + } + + if (textAudio.Type == RealtimeServerMessageType.OutputAudioTranscriptionDelta || textAudio.Type == RealtimeServerMessageType.OutputAudioTranscriptionDone) + { + return "transcription"; + } + } + + if (message is ResponseOutputItemRealtimeServerMessage) + { + return "item"; + } + + return null; + } + + /// Extracts an OTel message from a realtime client message. + private RealtimeOtelMessage? ExtractClientOtelMessage(RealtimeClientMessage message) + { + switch (message) + { + case CreateConversationItemRealtimeClientMessage createMsg: + return ExtractOtelMessage(createMsg.Item); + + case InputAudioBufferAppendRealtimeClientMessage audioAppendMsg: + var audioMessage = new RealtimeOtelMessage { Role = "user" }; + audioMessage.Parts.Add(new RealtimeOtelBlobPart + { + Content = audioAppendMsg.Content.Base64Data.ToString(), + MimeType = audioAppendMsg.Content.MediaType, + Modality = "audio", + }); + return audioMessage; + + case InputAudioBufferCommitRealtimeClientMessage: + // Commit message has no content, just a signal + return new RealtimeOtelMessage + { + Role = "user", + Parts = { new RealtimeOtelGenericPart { Type = "audio_commit" } }, + }; + + case CreateResponseRealtimeClientMessage responseCreateMsg: + var responseMessage = new RealtimeOtelMessage { Role = "user" }; + + // Add instructions if present + if (!string.IsNullOrWhiteSpace(responseCreateMsg.Instructions)) + { + responseMessage.Parts.Add(new RealtimeOtelGenericPart + { + Type = "instructions", + Content = responseCreateMsg.Instructions, + }); + } + + // Add items if present + if (responseCreateMsg.Items is { Count: > 0 } items) + { + foreach (var item in items) + { + var itemMessage = ExtractOtelMessage(item); + if (itemMessage is not null) + { + foreach (var part in itemMessage.Parts) + { + responseMessage.Parts.Add(part); + } + } + } + } + + return responseMessage.Parts.Count > 0 ? responseMessage : null; + + default: + return null; + } + } + + /// Extracts an OTel message from a realtime server message. + private RealtimeOtelMessage? ExtractServerOtelMessage(RealtimeServerMessage message) + { + switch (message) + { + case ResponseOutputItemRealtimeServerMessage outputItemMsg: + return ExtractOtelMessage(outputItemMsg.Item); + + case OutputTextAudioRealtimeServerMessage textAudioMsg: + string partType; + string? content; + + if (textAudioMsg.Type == RealtimeServerMessageType.OutputAudioDelta || textAudioMsg.Type == RealtimeServerMessageType.OutputAudioDone) + { + partType = "audio"; + content = string.IsNullOrEmpty(textAudioMsg.Audio) ? "[audio data]" : textAudioMsg.Audio; + } + else if (textAudioMsg.Type == RealtimeServerMessageType.OutputAudioTranscriptionDelta || textAudioMsg.Type == RealtimeServerMessageType.OutputAudioTranscriptionDone) + { + partType = "output_transcription"; + content = textAudioMsg.Text; + } + else + { + partType = "text"; + content = textAudioMsg.Text; + } + + // Skip if no meaningful content + if (string.IsNullOrEmpty(content)) + { + return null; + } + + var textAudioOtelMessage = new RealtimeOtelMessage { Role = "assistant" }; + textAudioOtelMessage.Parts.Add(new RealtimeOtelGenericPart + { + Type = partType, + Content = content, + }); + return textAudioOtelMessage; + + case InputAudioTranscriptionRealtimeServerMessage transcriptionMsg when !string.IsNullOrEmpty(transcriptionMsg.Transcription): + var transcriptionOtelMessage = new RealtimeOtelMessage { Role = "user" }; + transcriptionOtelMessage.Parts.Add(new RealtimeOtelGenericPart + { + Type = "input_transcription", + Content = transcriptionMsg.Transcription, + }); + return transcriptionOtelMessage; + + case ErrorRealtimeServerMessage errorMsg when errorMsg.Error is not null: + var errorOtelMessage = new RealtimeOtelMessage { Role = "system" }; + errorOtelMessage.Parts.Add(new RealtimeOtelGenericPart + { + Type = "error", + Content = errorMsg.Error.Message, + }); + return errorOtelMessage; + + case ResponseCreatedRealtimeServerMessage responseCreatedMsg when responseCreatedMsg.Items is { Count: > 0 }: + // Only capture items from ResponseCreated, not ResponseDone (which we use for tracing) + if (responseCreatedMsg.Type == RealtimeServerMessageType.ResponseCreated) + { + var responseOtelMessage = new RealtimeOtelMessage { Role = "assistant" }; + foreach (var item in responseCreatedMsg.Items) + { + var itemMessage = ExtractOtelMessage(item); + if (itemMessage is not null) + { + foreach (var part in itemMessage.Parts) + { + responseOtelMessage.Parts.Add(part); + } + } + } + + return responseOtelMessage.Parts.Count > 0 ? responseOtelMessage : null; + } + + return null; + + default: + return null; + } + } + + /// Serializes a single message to OTel format (as an array with one element). + private static string SerializeMessage(RealtimeOtelMessage message) + { + return JsonSerializer.Serialize(new[] { message }, RealtimeOtelContext.Default.IEnumerableRealtimeOtelMessage); + } + + /// Serializes content items to OTel format. + private static string SerializeMessages(IEnumerable messages) + { + return JsonSerializer.Serialize(messages, RealtimeOtelContext.Default.IEnumerableRealtimeOtelMessage); + } + + /// Extracts content from an AIContent list and converts to OTel format. + private RealtimeOtelMessage? ExtractOtelMessage(RealtimeConversationItem? item) + { + if (item?.Contents is null or { Count: 0 }) + { + return null; + } + + var message = new RealtimeOtelMessage + { + Role = item.Role?.Value, + }; + + foreach (var content in item.Contents) + { + switch (content) + { + // Standard text content + case TextContent tc when !string.IsNullOrEmpty(tc.Text): + message.Parts.Add(new RealtimeOtelGenericPart { Content = tc.Text }); + break; + + case TextReasoningContent trc when !string.IsNullOrEmpty(trc.Text): + message.Parts.Add(new RealtimeOtelGenericPart { Type = "reasoning", Content = trc.Text }); + break; + + // Function call content + case FunctionCallContent fcc: + message.Parts.Add(new RealtimeOtelToolCallPart + { + Id = fcc.CallId, + Name = fcc.Name, + Arguments = fcc.Arguments, + }); + break; + + case FunctionResultContent frc: + message.Parts.Add(new RealtimeOtelToolCallResponsePart + { + Id = frc.CallId, + Response = frc.Result, + }); + break; + + // Data content (binary data) + case DataContent dc: + message.Parts.Add(new RealtimeOtelBlobPart + { + Content = dc.Base64Data.ToString(), + MimeType = dc.MediaType, + Modality = DeriveModalityFromMediaType(dc.MediaType), + }); + break; + + // URI content + case UriContent uc: + message.Parts.Add(new RealtimeOtelUriPart + { + Uri = uc.Uri.AbsoluteUri, + MimeType = uc.MediaType, + Modality = DeriveModalityFromMediaType(uc.MediaType), + }); + break; + + // Hosted file content + case HostedFileContent fc: + message.Parts.Add(new RealtimeOtelFilePart + { + FileId = fc.FileId, + MimeType = fc.MediaType, + Modality = DeriveModalityFromMediaType(fc.MediaType), + }); + break; + + // Non-standard "generic" parts + case HostedVectorStoreContent vsc: + message.Parts.Add(new RealtimeOtelGenericPart { Type = "vector_store", Content = vsc.VectorStoreId }); + break; + + case ErrorContent ec: + message.Parts.Add(new RealtimeOtelGenericPart { Type = "error", Content = ec.Message }); + break; + + // MCP server tool content + case McpServerToolCallContent mstcc: + message.Parts.Add(new RealtimeOtelServerToolCallPart + { + Id = mstcc.CallId, + Name = mstcc.Name, + ServerToolCall = new RealtimeOtelMcpToolCall + { + Arguments = mstcc.Arguments as IReadOnlyDictionary ?? mstcc.Arguments?.ToDictionary(k => k.Key, v => v.Value), + ServerName = mstcc.ServerName, + }, + }); + break; + + case McpServerToolResultContent mstrc: + message.Parts.Add(new RealtimeOtelServerToolCallResponsePart + { + Id = mstrc.CallId, + ServerToolCallResponse = new RealtimeOtelMcpToolCallResponse + { + Output = mstrc.Outputs, + }, + }); + break; + + default: + // For unknown content types, try to serialize them + JsonElement element = default; + try + { + JsonTypeInfo? unknownContentTypeInfo = null; + if (_jsonSerializerOptions?.TryGetTypeInfo(content.GetType(), out JsonTypeInfo? ctsi) ?? false) + { + unknownContentTypeInfo = ctsi; + } + else if (AIJsonUtilities.DefaultOptions.TryGetTypeInfo(content.GetType(), out JsonTypeInfo? dtsi)) + { + unknownContentTypeInfo = dtsi; + } + + if (unknownContentTypeInfo is not null) + { + element = JsonSerializer.SerializeToElement(content, unknownContentTypeInfo); + } + } + catch + { + // Ignore serialization failures + } + + if (element.ValueKind != JsonValueKind.Undefined) + { + message.Parts.Add(new RealtimeOtelGenericPart + { + Type = content.GetType().Name, + Content = element, + }); + } + + break; + } + } + + return message.Parts.Count > 0 ? message : null; + } + + /// Derives modality from media type for telemetry purposes. + private static string? DeriveModalityFromMediaType(string? mediaType) + { + if (string.IsNullOrEmpty(mediaType)) + { + return null; + } + + if (mediaType!.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + return "image"; + } + + if (mediaType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase)) + { + return "audio"; + } + + if (mediaType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)) + { + return "video"; + } + + return null; + } + + /// Creates an activity for a realtime session request, or returns if not enabled. + private Activity? CreateAndConfigureActivity(RealtimeSessionOptions? options) + { + Activity? activity = null; + if (_activitySource.HasListeners()) + { + string? modelId = options?.Model ?? _defaultModelId; + + activity = _activitySource.StartActivity( + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.RealtimeName : $"{OpenTelemetryConsts.GenAI.RealtimeName} {modelId}", + ActivityKind.Client); + + if (activity is { IsAllDataRequested: true }) + { + _ = activity + .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.ChatName) + .AddTag(OpenTelemetryConsts.GenAI.Request.Model, modelId) + .AddTag(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (_serverAddress is not null) + { + _ = activity + .AddTag(OpenTelemetryConsts.Server.Address, _serverAddress) + .AddTag(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (options is not null) + { + // Standard GenAI attributes + if (options.MaxOutputTokens is int maxTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.MaxTokens, maxTokens); + } + + // Realtime-specific attributes + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Realtime.SessionKind, options.SessionKind.ToString()); + + if (!string.IsNullOrEmpty(options.Voice)) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Realtime.Voice, options.Voice); + } + + if (options.OutputModalities is { Count: > 0 } modalities) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Realtime.OutputModalities, $"[{string.Join(", ", modalities.Select(m => $"\"{m}\""))}]"); + } + + if (EnableSensitiveData) + { + if (!string.IsNullOrWhiteSpace(options.Instructions)) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.SystemInstructions, + JsonSerializer.Serialize(new object[1] { new RealtimeOtelGenericPart { Content = options.Instructions } }, RealtimeOtelContext.Default.IListObject)); + } + + if (options.Tools is { Count: > 0 }) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Tool.Definitions, + JsonSerializer.Serialize(options.Tools.Select(t => t switch + { + _ when t.GetService() is { } af => new RealtimeOtelFunction + { + Name = af.Name, + Description = af.Description, + Parameters = af.JsonSchema, + }, + _ => new RealtimeOtelFunction { Type = t.Name }, + }), RealtimeOtelContext.Default.IEnumerableRealtimeOtelFunction)); + } + } + } + } + } + + return activity; + } + + /// Adds streaming response information to the activity. + private void TraceStreamingResponse( + Activity? activity, + string? requestModelId, + ResponseCreatedRealtimeServerMessage? response, + Exception? error, + Stopwatch? stopwatch) + { + if (_operationDurationHistogram.Enabled && stopwatch is not null) + { + TagList tags = default; + AddMetricTags(ref tags, requestModelId, responseModelId: null); + + if (error is not null) + { + tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); + } + + _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); + } + + if (_tokenUsageHistogram.Enabled && response?.Usage is { } usage) + { + if (usage.InputTokenCount is long inputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput); + AddMetricTags(ref tags, requestModelId, responseModelId: null); + _tokenUsageHistogram.Record((int)inputTokens, tags); + } + + if (usage.OutputTokenCount is long outputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutput); + AddMetricTags(ref tags, requestModelId, responseModelId: null); + _tokenUsageHistogram.Record((int)outputTokens, tags); + } + + if (usage.InputAudioTokenCount is long inputAudioTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInputAudio); + AddMetricTags(ref tags, requestModelId, responseModelId: null); + _tokenUsageHistogram.Record((int)inputAudioTokens, tags); + } + + if (usage.InputTextTokenCount is long inputTextTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInputText); + AddMetricTags(ref tags, requestModelId, responseModelId: null); + _tokenUsageHistogram.Record((int)inputTextTokens, tags); + } + + if (usage.OutputAudioTokenCount is long outputAudioTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutputAudio); + AddMetricTags(ref tags, requestModelId, responseModelId: null); + _tokenUsageHistogram.Record((int)outputAudioTokens, tags); + } + + if (usage.OutputTextTokenCount is long outputTextTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutputText); + AddMetricTags(ref tags, requestModelId, responseModelId: null); + _tokenUsageHistogram.Record((int)outputTextTokens, tags); + } + } + + if (error is not null) + { + _ = activity? + .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + + if (_logger is not null) + { + OpenTelemetryLog.OperationException(_logger, error); + } + } + + if (response is not null && activity is not null) + { + // Log metadata first so standard tags take precedence if keys collide + if (EnableSensitiveData && response.AdditionalProperties is { } metadata) + { + foreach (var prop in metadata) + { + _ = activity.AddTag(prop.Key, prop.Value); + } + } + + if (!string.IsNullOrWhiteSpace(response.ResponseId)) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Id, response.ResponseId); + } + + if (!string.IsNullOrWhiteSpace(response.Status)) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.FinishReasons, $"[\"{response.Status}\"]"); + } + + if (response.Usage is { } responseUsage) + { + if (responseUsage.InputTokenCount is long inputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens); + } + + if (responseUsage.OutputTokenCount is long outputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens); + } + + if (responseUsage.CachedInputTokenCount is long cachedInputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.CacheReadInputTokens, (int)cachedInputTokens); + } + + if (responseUsage.InputAudioTokenCount is long inputAudioTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputAudioTokens, (int)inputAudioTokens); + } + + if (responseUsage.InputTextTokenCount is long inputTextTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTextTokens, (int)inputTextTokens); + } + + if (responseUsage.OutputAudioTokenCount is long outputAudioTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputAudioTokens, (int)outputAudioTokens); + } + + if (responseUsage.OutputTextTokenCount is long outputTextTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTextTokens, (int)outputTextTokens); + } + } + + // Log error content if available + if (response.Error is { } responseError) + { + _ = activity.AddTag(OpenTelemetryConsts.Error.Type, responseError.ErrorCode ?? "RealtimeError"); + _ = activity.SetStatus(ActivityStatusCode.Error, responseError.Message); + } + } + } + + private void AddMetricTags(ref TagList tags, string? requestModelId, string? responseModelId) + { + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.ChatName); + + if (requestModelId is not null) + { + tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); + } + + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (_serverAddress is string endpointAddress) + { + tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (responseModelId is string responseModel) + { + tags.Add(OpenTelemetryConsts.GenAI.Response.Model, responseModel); + } + } + + #region OTel Serialization Types + + private sealed class RealtimeOtelGenericPart + { + public string Type { get; set; } = "text"; + public object? Content { get; set; } + } + + private sealed class RealtimeOtelBlobPart + { + public string Type { get; set; } = "blob"; + public string? Content { get; set; } // base64-encoded binary data + public string? MimeType { get; set; } + public string? Modality { get; set; } + } + + private sealed class RealtimeOtelUriPart + { + public string Type { get; set; } = "uri"; + public string? Uri { get; set; } + public string? MimeType { get; set; } + public string? Modality { get; set; } + } + + private sealed class RealtimeOtelFilePart + { + public string Type { get; set; } = "file"; + public string? FileId { get; set; } + public string? MimeType { get; set; } + public string? Modality { get; set; } + } + + private sealed class RealtimeOtelFunction + { + public string Type { get; set; } = "function"; + public string? Name { get; set; } + public string? Description { get; set; } + public JsonElement? Parameters { get; set; } + } + + private sealed class RealtimeOtelMessage + { + public string? Role { get; set; } + public List Parts { get; set; } = []; + } + + private sealed class RealtimeOtelToolCallPart + { + public string Type { get; set; } = "tool_call"; + public string? Id { get; set; } + public string? Name { get; set; } + public IDictionary? Arguments { get; set; } + } + + private sealed class RealtimeOtelToolCallResponsePart + { + public string Type { get; set; } = "tool_call_response"; + public string? Id { get; set; } + public object? Response { get; set; } + } + + private sealed class RealtimeOtelServerToolCallPart + where T : class + { + public string Type { get; set; } = "server_tool_call"; + public string? Id { get; set; } + public string? Name { get; set; } + public T? ServerToolCall { get; set; } + } + + private sealed class RealtimeOtelServerToolCallResponsePart + where T : class + { + public string Type { get; set; } = "server_tool_call_response"; + public string? Id { get; set; } + public T? ServerToolCallResponse { get; set; } + } + + private sealed class RealtimeOtelMcpToolCall + { + public string Type { get; set; } = "mcp"; + public string? ServerName { get; set; } + public IReadOnlyDictionary? Arguments { get; set; } + } + + private sealed class RealtimeOtelMcpToolCallResponse + { + public string Type { get; set; } = "mcp"; + public object? Output { get; set; } + } + + #endregion + + [JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + [JsonSerializable(typeof(IList))] + [JsonSerializable(typeof(RealtimeOtelGenericPart))] + [JsonSerializable(typeof(RealtimeOtelBlobPart))] + [JsonSerializable(typeof(RealtimeOtelUriPart))] + [JsonSerializable(typeof(RealtimeOtelFilePart))] + [JsonSerializable(typeof(IEnumerable))] + [JsonSerializable(typeof(IEnumerable))] + [JsonSerializable(typeof(RealtimeOtelMessage))] + [JsonSerializable(typeof(RealtimeOtelToolCallPart))] + [JsonSerializable(typeof(RealtimeOtelToolCallResponsePart))] + [JsonSerializable(typeof(RealtimeOtelServerToolCallPart))] + [JsonSerializable(typeof(RealtimeOtelServerToolCallResponsePart))] + + private sealed partial class RealtimeOtelContext : JsonSerializerContext; +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientBuilder.cs new file mode 100644 index 00000000000..ce42d18e027 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientBuilder.cs @@ -0,0 +1,89 @@ +// 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 Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A builder for creating pipelines of . +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class RealtimeClientBuilder +{ + private readonly Func _innerClientFactory; + + /// The registered client factory instances. + private List>? _clientFactories; + + /// Initializes a new instance of the class. + /// The inner that represents the underlying backend. + /// is . + public RealtimeClientBuilder(IRealtimeClient innerClient) + { + _ = Throw.IfNull(innerClient); + _innerClientFactory = _ => innerClient; + } + + /// Initializes a new instance of the class. + /// A callback that produces the inner that represents the underlying backend. + public RealtimeClientBuilder(Func innerClientFactory) + { + _innerClientFactory = Throw.IfNull(innerClientFactory); + } + + /// Builds an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. + /// + /// The that should provide services to the instances. + /// If , an empty will be used. + /// + /// An instance of that represents the entire pipeline. + public IRealtimeClient Build(IServiceProvider? services = null) + { + services ??= EmptyServiceProvider.Instance; + var client = _innerClientFactory(services); + + // To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost. + if (_clientFactories is not null) + { + for (var i = _clientFactories.Count - 1; i >= 0; i--) + { + client = _clientFactories[i](client, services); + if (client is null) + { + Throw.InvalidOperationException( + $"The {nameof(RealtimeClientBuilder)} entry at index {i} returned null. " + + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IRealtimeClient)} instances."); + } + } + } + + return client; + } + + /// Adds a factory for an intermediate realtime client to the realtime client pipeline. + /// The client factory function. + /// The updated instance. + /// is . + public RealtimeClientBuilder Use(Func clientFactory) + { + _ = Throw.IfNull(clientFactory); + + return Use((innerClient, _) => clientFactory(innerClient)); + } + + /// Adds a factory for an intermediate realtime client to the realtime client pipeline. + /// The client factory function. + /// The updated instance. + /// is . + public RealtimeClientBuilder Use(Func clientFactory) + { + _ = Throw.IfNull(clientFactory); + + (_clientFactories ??= []).Add(clientFactory); + return this; + } + +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientBuilderRealtimeClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientBuilderRealtimeClientExtensions.cs new file mode 100644 index 00000000000..7be2a697059 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientBuilderRealtimeClientExtensions.cs @@ -0,0 +1,29 @@ +// 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.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for working with in the context of . +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public static class RealtimeClientBuilderRealtimeClientExtensions +{ + /// Creates a new using as its inner client. + /// The client to use as the inner client. + /// The new instance. + /// + /// This method is equivalent to using the constructor directly, + /// specifying as the inner client. + /// + /// is . + public static RealtimeClientBuilder AsBuilder(this IRealtimeClient innerClient) + { + _ = Throw.IfNull(innerClient); + + return new RealtimeClientBuilder(innerClient); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientExtensions.cs new file mode 100644 index 00000000000..44837e26283 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientExtensions.cs @@ -0,0 +1,82 @@ +// 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.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides a collection of static methods for extending instances. +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public static class RealtimeClientExtensions +{ + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// The client. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService? GetService(this IRealtimeClient client, object? serviceKey = null) + { + _ = Throw.IfNull(client); + + return client.GetService(typeof(TService), serviceKey) is TService service ? service : default; + } + + /// + /// Asks the for an object of the specified type + /// and throws an exception if one isn't available. + /// + /// The client. + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static object GetRequiredService(this IRealtimeClient client, Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(client); + _ = Throw.IfNull(serviceType); + + return + client.GetService(serviceType, serviceKey) ?? + throw Throw.CreateMissingServiceException(serviceType, serviceKey); + } + + /// + /// Asks the for an object of type + /// and throws an exception if one isn't available. + /// + /// The type of the object to be retrieved. + /// The client. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService GetRequiredService(this IRealtimeClient client, object? serviceKey = null) + { + _ = Throw.IfNull(client); + + if (client.GetService(typeof(TService), serviceKey) is not TService service) + { + throw Throw.CreateMissingServiceException(typeof(TService), serviceKey); + } + + return service; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientSessionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientSessionExtensions.cs new file mode 100644 index 00000000000..4e4ccbd21ef --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Realtime/RealtimeClientSessionExtensions.cs @@ -0,0 +1,82 @@ +// 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.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides a collection of static methods for extending instances. +[Experimental(DiagnosticIds.Experiments.AIRealTime, UrlFormat = DiagnosticIds.UrlFormat)] +public static class RealtimeClientSessionExtensions +{ + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// The session. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService? GetService(this IRealtimeClientSession session, object? serviceKey = null) + { + _ = Throw.IfNull(session); + + return session.GetService(typeof(TService), serviceKey) is TService service ? service : default; + } + + /// + /// Asks the for an object of the specified type + /// and throws an exception if one isn't available. + /// + /// The session. + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static object GetRequiredService(this IRealtimeClientSession session, Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(session); + _ = Throw.IfNull(serviceType); + + return + session.GetService(serviceType, serviceKey) ?? + throw Throw.CreateMissingServiceException(serviceType, serviceKey); + } + + /// + /// Asks the for an object of type + /// and throws an exception if one isn't available. + /// + /// The type of the object to be retrieved. + /// The session. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService GetRequiredService(this IRealtimeClientSession session, object? serviceKey = null) + { + _ = Throw.IfNull(session); + + if (session.GetService(typeof(TService), serviceKey) is not TService service) + { + throw Throw.CreateMissingServiceException(typeof(TService), serviceKey); + } + + return service; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs index 1b87dbf5d1f..023039df208 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs @@ -1,4 +1,4 @@ -// 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; @@ -39,17 +39,19 @@ public sealed class OpenTelemetrySpeechToTextClient : DelegatingSpeechToTextClie private readonly string? _serverAddress; private readonly int _serverPort; + private readonly ILogger? _logger; + /// Initializes a new instance of the class. /// The underlying . /// The to use for emitting any logging data from the client. /// An optional source name that will be used on the telemetry data. -#pragma warning disable IDE0060 // Remove unused parameter; it exists for consistency with IChatClient and future use public OpenTelemetrySpeechToTextClient(ISpeechToTextClient innerClient, ILogger? logger = null, string? sourceName = null) -#pragma warning restore IDE0060 : base(innerClient) { Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); + _logger = logger; + if (innerClient!.GetService() is SpeechToTextClientMetadata metadata) { _defaultModelId = metadata.DefaultModelId; @@ -288,6 +290,11 @@ private void TraceResponse( _ = activity? .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) .SetStatus(ActivityStatusCode.Error, error.Message); + + if (_logger is not null) + { + OpenTelemetryLog.OperationException(_logger, error); + } } if (response is not null) diff --git a/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/ConfigureOptionsTextToSpeechClient.cs b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/ConfigureOptionsTextToSpeechClient.cs new file mode 100644 index 00000000000..bd6429a3a97 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/ConfigureOptionsTextToSpeechClient.cs @@ -0,0 +1,65 @@ +// 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.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating text to speech client that configures a instance used by the remainder of the pipeline. +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class ConfigureOptionsTextToSpeechClient : DelegatingTextToSpeechClient +{ + /// The callback delegate used to configure options. + private readonly Action _configureOptions; + + /// Initializes a new instance of the class with the specified callback. + /// The inner client. + /// + /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied instance + /// (or a newly constructed instance if the caller-supplied instance is ). + /// + /// + /// The delegate is passed either a new instance of if + /// the caller didn't supply a instance, or a clone (via of the caller-supplied + /// instance if one was supplied. + /// + public ConfigureOptionsTextToSpeechClient(ITextToSpeechClient innerClient, Action configure) + : base(innerClient) + { + _configureOptions = Throw.IfNull(configure); + } + + /// + public override async Task GetAudioAsync( + string text, TextToSpeechOptions? options = null, CancellationToken cancellationToken = default) + { + return await base.GetAudioAsync(text, Configure(options), cancellationToken); + } + + /// + public override async IAsyncEnumerable GetStreamingAudioAsync( + string text, TextToSpeechOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var update in base.GetStreamingAudioAsync(text, Configure(options), cancellationToken)) + { + yield return update; + } + } + + /// Creates and configures the to pass along to the inner client. + private TextToSpeechOptions Configure(TextToSpeechOptions? options) + { + options = options?.Clone() ?? new(); + + _configureOptions(options); + + return options; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/ConfigureOptionsTextToSpeechClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/ConfigureOptionsTextToSpeechClientBuilderExtensions.cs new file mode 100644 index 00000000000..a4ec671103f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/ConfigureOptionsTextToSpeechClientBuilderExtensions.cs @@ -0,0 +1,37 @@ +// 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.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public static class ConfigureOptionsTextToSpeechClientBuilderExtensions +{ + /// + /// Adds a callback that configures a to be passed to the next client in the pipeline. + /// + /// The . + /// + /// The delegate to invoke to configure the instance. + /// It is passed a clone of the caller-supplied instance (or a newly constructed instance if the caller-supplied instance is ). + /// + /// + /// This method can be used to set default options. The delegate is passed either a new instance of + /// if the caller didn't supply a instance, or a clone (via ) + /// of the caller-supplied instance if one was supplied. + /// + /// The . + public static TextToSpeechClientBuilder ConfigureOptions( + this TextToSpeechClientBuilder builder, Action configure) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(configure); + + return builder.Use(innerClient => new ConfigureOptionsTextToSpeechClient(innerClient, configure)); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/LoggingTextToSpeechClient.cs b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/LoggingTextToSpeechClient.cs new file mode 100644 index 00000000000..c1618a621e7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/LoggingTextToSpeechClient.cs @@ -0,0 +1,189 @@ +// 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.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating text to speech client that logs text to speech operations to an . +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// employed is also thread-safe for concurrent use. +/// +/// +/// When the employed enables , the contents of +/// messages and options are logged. These messages and options may contain sensitive application data. +/// is disabled by default and should never be enabled in a production environment. +/// Messages and options are not logged at other logging levels. +/// +/// +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public partial class LoggingTextToSpeechClient : DelegatingTextToSpeechClient +{ + /// An instance used for all logging. + private readonly ILogger _logger; + + /// The to use for serialization of state written to the logger. + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used for all logging. + public LoggingTextToSpeechClient(ITextToSpeechClient innerClient, ILogger logger) + : base(innerClient) + { + _logger = Throw.IfNull(logger); + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; + } + + /// Gets or sets JSON serialization options to use when serializing logging data. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public override async Task GetAudioAsync( + string text, TextToSpeechOptions? options = null, CancellationToken cancellationToken = default) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokedSensitive(nameof(GetAudioAsync), AsJson(options), AsJson(this.GetService())); + } + else + { + LogInvoked(nameof(GetAudioAsync)); + } + } + + try + { + var response = await base.GetAudioAsync(text, options, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + // TTS responses always contain binary audio data; avoid serializing it. + LogCompleted(nameof(GetAudioAsync)); + } + + return response; + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GetAudioAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GetAudioAsync), ex); + throw; + } + } + + /// + public override async IAsyncEnumerable GetStreamingAudioAsync( + string text, TextToSpeechOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokedSensitive(nameof(GetStreamingAudioAsync), AsJson(options), AsJson(this.GetService())); + } + else + { + LogInvoked(nameof(GetStreamingAudioAsync)); + } + } + + IAsyncEnumerator e; + try + { + e = base.GetStreamingAudioAsync(text, options, cancellationToken).GetAsyncEnumerator(cancellationToken); + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GetStreamingAudioAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GetStreamingAudioAsync), ex); + throw; + } + + try + { + TextToSpeechResponseUpdate? update = null; + while (true) + { + try + { + if (!await e.MoveNextAsync()) + { + break; + } + + update = e.Current; + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GetStreamingAudioAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GetStreamingAudioAsync), ex); + throw; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + // TTS updates always contain binary audio data; avoid serializing it. + LogStreamingUpdate(); + } + + yield return update; + } + + LogCompleted(nameof(GetStreamingAudioAsync)); + } + finally + { + await e.DisposeAsync(); + } + } + + private string AsJson(T value) => TelemetryHelpers.AsJson(value, _jsonSerializerOptions); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] + private partial void LogInvoked(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: Options: {TextToSpeechOptions}. Metadata: {TextToSpeechClientMetadata}.")] + private partial void LogInvokedSensitive(string methodName, string textToSpeechOptions, string textToSpeechClientMetadata); + + [LoggerMessage(LogLevel.Debug, "{MethodName} completed.")] + private partial void LogCompleted(string methodName); + + [LoggerMessage(LogLevel.Debug, "GetStreamingAudioAsync received update.")] + private partial void LogStreamingUpdate(); + + [LoggerMessage(LogLevel.Debug, "{MethodName} canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/LoggingTextToSpeechClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/LoggingTextToSpeechClientBuilderExtensions.cs new file mode 100644 index 00000000000..4f2b16499d0 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/LoggingTextToSpeechClientBuilderExtensions.cs @@ -0,0 +1,57 @@ +// 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.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public static class LoggingTextToSpeechClientBuilderExtensions +{ + /// Adds logging to the text-to-speech client pipeline. + /// The . + /// + /// An optional used to create a logger with which logging should be performed. + /// 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 . + /// + /// + /// When the employed enables , the contents of + /// messages and options are logged. These messages and options may contain sensitive application data. + /// is disabled by default and should never be enabled in a production environment. + /// Messages and options are not logged at other logging levels. + /// + /// + public static TextToSpeechClientBuilder UseLogging( + this TextToSpeechClientBuilder builder, + ILoggerFactory? loggerFactory = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + { + loggerFactory ??= services.GetRequiredService(); + + // If the factory we resolve is for the null logger, the LoggingTextToSpeechClient will end up + // being an expensive nop, so skip adding it and just return the inner client. + if (loggerFactory == NullLoggerFactory.Instance) + { + return innerClient; + } + + var client = new LoggingTextToSpeechClient(innerClient, loggerFactory.CreateLogger(typeof(LoggingTextToSpeechClient))); + configure?.Invoke(client); + return client; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/OpenTelemetryTextToSpeechClient.cs b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/OpenTelemetryTextToSpeechClient.cs new file mode 100644 index 00000000000..19b7040ef7d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/OpenTelemetryTextToSpeechClient.cs @@ -0,0 +1,355 @@ +// 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; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3358 // Ternary operators should not be nested +#pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter +#pragma warning disable SA1113 // Comma should be on the same line as previous parameter + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating text-to-speech client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.40, defined at . +/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. +/// +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class OpenTelemetryTextToSpeechClient : DelegatingTextToSpeechClient +{ + private readonly ActivitySource _activitySource; + private readonly Meter _meter; + + private readonly Histogram _tokenUsageHistogram; + private readonly Histogram _operationDurationHistogram; + + private readonly string? _defaultModelId; + private readonly string? _providerName; + private readonly string? _serverAddress; + private readonly int _serverPort; + + private readonly ILogger? _logger; + + /// Initializes a new instance of the class. + /// The underlying . + /// The to use for emitting any logging data from the client. + /// An optional source name that will be used on the telemetry data. + public OpenTelemetryTextToSpeechClient(ITextToSpeechClient innerClient, ILogger? logger = null, string? sourceName = null) + : base(innerClient) + { + Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); + + _logger = logger; + + if (innerClient!.GetService() is TextToSpeechClientMetadata metadata) + { + _defaultModelId = metadata.DefaultModelId; + _providerName = metadata.ProviderName; + _serverAddress = metadata.ProviderUri?.Host; + _serverPort = metadata.ProviderUri?.Port ?? 0; + } + + string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + _activitySource = new(name); + _meter = new(name); + + _tokenUsageHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, + OpenTelemetryConsts.TokensUnit, + 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, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } + ); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activitySource.Dispose(); + _meter.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. + /// + /// + /// if potentially sensitive information should be included in telemetry; + /// if telemetry shouldn't include raw inputs and outputs. + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). + /// + /// + /// By default, telemetry includes metadata, such as token counts, but not raw inputs + /// and outputs, such as message content, function call arguments, and function call results. + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. + /// + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ActivitySource) ? _activitySource : + base.GetService(serviceType, serviceKey); + + /// + public override async Task GetAudioAsync(string text, TextToSpeechOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(text); + + using Activity? activity = CreateAndConfigureActivity(options); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _defaultModelId; + + TextToSpeechResponse? response = null; + Exception? error = null; + try + { + response = await base.GetAudioAsync(text, options, cancellationToken); + return response; + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + TraceResponse(activity, requestModelId, response, error, stopwatch); + } + } + + /// + public override async IAsyncEnumerable GetStreamingAudioAsync( + string text, TextToSpeechOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(text); + + using Activity? activity = CreateAndConfigureActivity(options); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _defaultModelId; + + IAsyncEnumerable updates; + try + { + updates = base.GetStreamingAudioAsync(text, options, cancellationToken); + } + catch (Exception ex) + { + TraceResponse(activity, requestModelId, response: null, ex, stopwatch); + throw; + } + + var responseEnumerator = updates.GetAsyncEnumerator(cancellationToken); + List trackedUpdates = []; + Exception? error = null; + try + { + while (true) + { + TextToSpeechResponseUpdate update; + try + { + if (!await responseEnumerator.MoveNextAsync()) + { + break; + } + + update = responseEnumerator.Current; + } + catch (Exception ex) + { + error = ex; + throw; + } + + trackedUpdates.Add(update); + yield return update; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + } + finally + { + TraceResponse(activity, requestModelId, trackedUpdates.ToTextToSpeechResponse(), error, stopwatch); + + await responseEnumerator.DisposeAsync(); + } + } + + /// Creates an activity for a text-to-speech request, or returns if not enabled. + private Activity? CreateAndConfigureActivity(TextToSpeechOptions? options) + { + Activity? activity = null; + if (_activitySource.HasListeners()) + { + string? modelId = options?.ModelId ?? _defaultModelId; + + activity = _activitySource.StartActivity( + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.GenerateContentName : $"{OpenTelemetryConsts.GenAI.GenerateContentName} {modelId}", + ActivityKind.Client); + + if (activity is { IsAllDataRequested: true }) + { + _ = activity + .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.GenerateContentName) + .AddTag(OpenTelemetryConsts.GenAI.Request.Model, modelId) + .AddTag(OpenTelemetryConsts.GenAI.Provider.Name, _providerName) + .AddTag(OpenTelemetryConsts.GenAI.Output.Type, OpenTelemetryConsts.TypeAudio); + + if (_serverAddress is not null) + { + _ = activity + .AddTag(OpenTelemetryConsts.Server.Address, _serverAddress) + .AddTag(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (options is not null) + { + if (EnableSensitiveData) + { + // Log all additional request options as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (options.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag(prop.Key, prop.Value); + } + } + } + } + } + } + + return activity; + } + + /// Adds text-to-speech response information to the activity. + private void TraceResponse( + Activity? activity, + string? requestModelId, + TextToSpeechResponse? response, + Exception? error, + Stopwatch? stopwatch) + { + if (_operationDurationHistogram.Enabled && stopwatch is not null) + { + TagList tags = default; + + AddMetricTags(ref tags, requestModelId, response); + if (error is not null) + { + tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); + } + + _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); + } + + if (_tokenUsageHistogram.Enabled && response?.Usage is { } usage) + { + if (usage.InputTokenCount is long inputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput); + AddMetricTags(ref tags, requestModelId, response); + _tokenUsageHistogram.Record((int)inputTokens, tags); + } + + if (usage.OutputTokenCount is long outputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutput); + AddMetricTags(ref tags, requestModelId, response); + _tokenUsageHistogram.Record((int)outputTokens, tags); + } + } + + if (error is not null) + { + _ = activity? + .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + + if (_logger is not null) + { + OpenTelemetryLog.OperationException(_logger, error); + } + } + + if (response is not null && activity is not null) + { + if (!string.IsNullOrWhiteSpace(response.ResponseId)) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Id, response.ResponseId); + } + + if (response.ModelId is not null) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Model, response.ModelId); + } + + if (response.Usage?.InputTokenCount is long inputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens); + } + + if (response.Usage?.OutputTokenCount is long outputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens); + } + + // Log all additional response properties as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (EnableSensitiveData && response.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag(prop.Key, prop.Value); + } + } + } + + void AddMetricTags(ref TagList tags, string? requestModelId, TextToSpeechResponse? response) + { + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.GenerateContentName); + + if (requestModelId is not null) + { + tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); + } + + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (_serverAddress is string endpointAddress) + { + tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (response?.ModelId is string responseModel) + { + tags.Add(OpenTelemetryConsts.GenAI.Response.Model, responseModel); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/OpenTelemetryTextToSpeechClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/OpenTelemetryTextToSpeechClientBuilderExtensions.cs new file mode 100644 index 00000000000..bdbe51eb122 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/OpenTelemetryTextToSpeechClientBuilderExtensions.cs @@ -0,0 +1,43 @@ +// 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.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public static class OpenTelemetryTextToSpeechClientBuilderExtensions +{ + /// + /// Adds OpenTelemetry support to the text-to-speech client pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// + /// The draft specification this follows is available at . + /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. + /// + /// The . + /// An optional to use to create a logger for logging events. + /// An optional source name that will be used on the telemetry data. + /// An optional callback that can be used to configure the instance. + /// The . + public static TextToSpeechClientBuilder UseOpenTelemetry( + this TextToSpeechClientBuilder builder, + ILoggerFactory? loggerFactory = null, + string? sourceName = null, + Action? configure = null) => + Throw.IfNull(builder).Use((innerClient, services) => + { + loggerFactory ??= services.GetService(); + + var client = new OpenTelemetryTextToSpeechClient(innerClient, loggerFactory?.CreateLogger(typeof(OpenTelemetryTextToSpeechClient)), sourceName); + configure?.Invoke(client); + + return client; + }); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilder.cs new file mode 100644 index 00000000000..8c116d42fa4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilder.cs @@ -0,0 +1,82 @@ +// 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 Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A builder for creating pipelines of . +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class TextToSpeechClientBuilder +{ + private readonly Func _innerClientFactory; + + /// The registered client factory instances. + private List>? _clientFactories; + + /// Initializes a new instance of the class. + /// The inner that represents the underlying backend. + public TextToSpeechClientBuilder(ITextToSpeechClient innerClient) + { + _ = Throw.IfNull(innerClient); + _innerClientFactory = _ => innerClient; + } + + /// Initializes a new instance of the class. + /// A callback that produces the inner that represents the underlying backend. + public TextToSpeechClientBuilder(Func innerClientFactory) + { + _innerClientFactory = Throw.IfNull(innerClientFactory); + } + + /// Builds an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. + /// + /// The that should provide services to the instances. + /// If null, an empty will be used. + /// + /// An instance of that represents the entire pipeline. + public ITextToSpeechClient Build(IServiceProvider? services = null) + { + services ??= EmptyServiceProvider.Instance; + var client = _innerClientFactory(services); + + // To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost. + if (_clientFactories is not null) + { + for (var i = _clientFactories.Count - 1; i >= 0; i--) + { + client = _clientFactories[i](client, services) ?? + throw new InvalidOperationException( + $"The {nameof(TextToSpeechClientBuilder)} entry at index {i} returned null. " + + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(ITextToSpeechClient)} instances."); + } + } + + return client; + } + + /// Adds a factory for an intermediate text-to-speech client to the text-to-speech client pipeline. + /// The client factory function. + /// The updated instance. + public TextToSpeechClientBuilder Use(Func clientFactory) + { + _ = Throw.IfNull(clientFactory); + + return Use((innerClient, _) => clientFactory(innerClient)); + } + + /// Adds a factory for an intermediate text-to-speech client to the text-to-speech client pipeline. + /// The client factory function. + /// The updated instance. + public TextToSpeechClientBuilder Use(Func clientFactory) + { + _ = Throw.IfNull(clientFactory); + + (_clientFactories ??= []).Add(clientFactory); + return this; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilderServiceCollectionExtensions.cs new file mode 100644 index 00000000000..c71a2c76fe8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilderServiceCollectionExtensions.cs @@ -0,0 +1,89 @@ +// 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.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DependencyInjection; + +/// Provides extension methods for registering with a . +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public static class TextToSpeechClientBuilderServiceCollectionExtensions +{ + /// Registers a singleton in the . + /// The to which the client should be added. + /// The inner that represents the underlying backend. + /// The service lifetime for the client. Defaults to . + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a singleton service. + public static TextToSpeechClientBuilder AddTextToSpeechClient( + this IServiceCollection serviceCollection, + ITextToSpeechClient innerClient, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerClient); + return AddTextToSpeechClient(serviceCollection, _ => innerClient, lifetime); + } + + /// Registers a singleton in the . + /// The to which the client should be added. + /// A callback that produces the inner that represents the underlying backend. + /// The service lifetime for the client. Defaults to . + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a singleton service. + public static TextToSpeechClientBuilder AddTextToSpeechClient( + this IServiceCollection serviceCollection, + Func innerClientFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerClientFactory); + + var builder = new TextToSpeechClientBuilder(innerClientFactory); + serviceCollection.Add(new ServiceDescriptor(typeof(ITextToSpeechClient), builder.Build, lifetime)); + return builder; + } + + /// Registers a keyed singleton in the . + /// The to which the client should be added. + /// The key with which to associate the client. + /// The inner that represents the underlying backend. + /// The service lifetime for the client. Defaults to . + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a singleton service by default. + public static TextToSpeechClientBuilder AddKeyedTextToSpeechClient( + this IServiceCollection serviceCollection, + object? serviceKey, + ITextToSpeechClient innerClient, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerClient); + return AddKeyedTextToSpeechClient(serviceCollection, serviceKey, _ => innerClient, lifetime); + } + + /// Registers a keyed singleton in the . + /// The to which the client should be added. + /// The key with which to associate the client. + /// A callback that produces the inner that represents the underlying backend. + /// The service lifetime for the client. Defaults to . + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a singleton service by default. + public static TextToSpeechClientBuilder AddKeyedTextToSpeechClient( + this IServiceCollection serviceCollection, + object? serviceKey, + Func innerClientFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerClientFactory); + + var builder = new TextToSpeechClientBuilder(innerClientFactory); + serviceCollection.Add(new ServiceDescriptor(typeof(ITextToSpeechClient), serviceKey, factory: (services, serviceKey) => builder.Build(services), lifetime)); + return builder; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilderTextToSpeechClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilderTextToSpeechClientExtensions.cs new file mode 100644 index 00000000000..fed94eccd0c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/TextToSpeech/TextToSpeechClientBuilderTextToSpeechClientExtensions.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; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for working with in the context of . +[Experimental(DiagnosticIds.Experiments.AITextToSpeech, UrlFormat = DiagnosticIds.UrlFormat)] +public static class TextToSpeechClientBuilderTextToSpeechClientExtensions +{ + /// Creates a new using as its inner client. + /// The client to use as the inner client. + /// The new instance. + /// + /// This method is equivalent to using the constructor directly, + /// specifying as the inner client. + /// + public static TextToSpeechClientBuilder AsBuilder(this ITextToSpeechClient innerClient) + { + _ = Throw.IfNull(innerClient); + + return new TextToSpeechClientBuilder(innerClient); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Throw.cs b/src/Libraries/Microsoft.Extensions.AI/Throw.cs new file mode 100644 index 00000000000..0d8f0db7fe5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Throw.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. + +using System; + +namespace Microsoft.Shared.Diagnostics; + +internal static partial class Throw +{ + /// Throws an exception indicating that a required service is not available. + public static InvalidOperationException CreateMissingServiceException(Type serviceType, object? serviceKey) => + new InvalidOperationException(serviceKey is null ? + $"No service of type '{serviceType}' is available." : + $"No service of type '{serviceType}' for the key '{serviceKey}' is available."); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/CHANGELOG.md deleted file mode 100644 index 7cf926bb99a..00000000000 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/CHANGELOG.md +++ /dev/null @@ -1,5 +0,0 @@ -# Release History - -## 10.0.0-preview.1 - -- Initial preview release diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/CHANGELOG.md deleted file mode 100644 index 7cf926bb99a..00000000000 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/CHANGELOG.md +++ /dev/null @@ -1,5 +0,0 @@ -# Release History - -## 10.0.0-preview.1 - -- Initial preview release diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/CHANGELOG.md deleted file mode 100644 index 7cf926bb99a..00000000000 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/CHANGELOG.md +++ /dev/null @@ -1,5 +0,0 @@ -# Release History - -## 10.0.0-preview.1 - -- Initial preview release diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.DataIngestion/CHANGELOG.md deleted file mode 100644 index a88260e298f..00000000000 --- a/src/Libraries/Microsoft.Extensions.DataIngestion/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# Release History - -## 10.1.0-preview.1 - -- Introduced `SectionChunker` class for treating each document section as a separate entity (https://github.com/dotnet/extensions/pull/7015) - -## 10.0.0-preview.1 - -- Initial preview release diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriter.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriter.cs index 5b312836732..7969f99035a 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriter.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriter.cs @@ -169,7 +169,13 @@ private async Task> GetPreExistingChunksIdsAsync(Ingestion } // Each Vector Store has a different max top count limit, so we use low value and loop. - const int MaxTopCount = 1_000; + // Use smaller batch size in debug to be able to test the looping logic without needing to insert a lot of records. + const int MaxTopCount = +#if RELEASE + 1_000; +#else + 10; +#endif List keys = []; int insertedCount; diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs index acf8baa47f0..91592d5819a 100644 --- a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs @@ -61,6 +61,12 @@ public ValueTask ResolveServiceAsync(string name, CancellationT ObjectDisposedException.ThrowIf(_disposed, this); cancellationToken.ThrowIfCancellationRequested(); + if (CheckIsReservedDnsName(name) is ReservedNameType type && type != ReservedNameType.None) + { + // RFC 6761 requires that libraries return negative results for all queries except localhost A/AAAA + return ValueTask.FromResult(Array.Empty()); + } + // dnsSafeName is Disposed by SendQueryWithTelemetry EncodedDomainName dnsSafeName = GetNormalizedHostName(name); return SendQueryWithTelemetry(name, dnsSafeName, QueryType.SRV, ProcessResponse, cancellationToken); @@ -109,24 +115,9 @@ public ValueTask ResolveServiceAsync(string name, CancellationT public async ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default) { - if (string.Equals(name, "localhost", StringComparison.OrdinalIgnoreCase)) + if (CheckIsReservedDnsName(name) is ReservedNameType type && type != ReservedNameType.None) { - // name localhost exists outside of DNS and can't be resolved by a DNS server - int len = (Socket.OSSupportsIPv4 ? 1 : 0) + (Socket.OSSupportsIPv6 ? 1 : 0); - AddressResult[] res = new AddressResult[len]; - - int index = 0; - if (Socket.OSSupportsIPv6) // prefer IPv6 - { - res[index] = new AddressResult(DateTime.MaxValue, IPAddress.IPv6Loopback); - index++; - } - if (Socket.OSSupportsIPv4) - { - res[index] = new AddressResult(DateTime.MaxValue, IPAddress.Loopback); - } - - return res; + return ResolveDnsReservedNameAddress(type, null); } var ipv4AddressesTask = ResolveIPAddressesAsync(name, AddressFamily.InterNetwork, cancellationToken); @@ -151,19 +142,9 @@ internal ValueTask ResolveIPAddressesAsync(string name, Address throw new ArgumentOutOfRangeException(nameof(addressFamily), addressFamily, "Invalid address family"); } - if (string.Equals(name, "localhost", StringComparison.OrdinalIgnoreCase)) + if (CheckIsReservedDnsName(name) is ReservedNameType type && type != ReservedNameType.None) { - // name localhost exists outside of DNS and can't be resolved by a DNS server - if (addressFamily == AddressFamily.InterNetwork && Socket.OSSupportsIPv4) - { - return ValueTask.FromResult([new AddressResult(DateTime.MaxValue, IPAddress.Loopback)]); - } - else if (addressFamily == AddressFamily.InterNetworkV6 && Socket.OSSupportsIPv6) - { - return ValueTask.FromResult([new AddressResult(DateTime.MaxValue, IPAddress.IPv6Loopback)]); - } - - return ValueTask.FromResult([]); + return ValueTask.FromResult(ResolveDnsReservedNameAddress(type, addressFamily)); } // dnsSafeName is Disposed by SendQueryWithTelemetry @@ -340,6 +321,41 @@ static bool TryReadAddress(in DnsResourceRecord record, QueryType type, [NotNull } } + private static AddressResult[] ResolveDnsReservedNameAddress(ReservedNameType type, AddressFamily? addressFamily) + { + switch (type) + { + case ReservedNameType.Localhost: + // resolve to appropriate loopback address + bool doIpv6 = Socket.OSSupportsIPv6 && (addressFamily == null || addressFamily == AddressFamily.InterNetworkV6); + bool doIpv4 = Socket.OSSupportsIPv4 && (addressFamily == null || addressFamily == AddressFamily.InterNetwork); + + int len = (doIpv4 ? 1 : 0) + (doIpv6 ? 1 : 0); + AddressResult[] res = new AddressResult[len]; + + int count = 0; + if (doIpv6) // put IPv6 first if both are requested + { + res[count] = new AddressResult(DateTime.MaxValue, IPAddress.IPv6Loopback); + count++; + } + if (doIpv4) + { + res[count] = new AddressResult(DateTime.MaxValue, IPAddress.Loopback); + } + + return res; + + case ReservedNameType.Invalid: + // RFC 6761 requires that libraries return negative results for 'invalid' + return []; + + default: + Debug.Fail("Should be unreachable"); + throw new ArgumentOutOfRangeException(nameof(type), type, "Invalid reserved name type"); + } + } + private async ValueTask SendQueryWithTelemetry(string name, EncodedDomainName dnsSafeName, QueryType queryType, Func processResponseFunc, CancellationToken cancellationToken) { NameResolutionActivity activity = Telemetry.StartNameResolution(name, queryType, _timeProvider.GetTimestamp()); @@ -926,4 +942,44 @@ private static EncodedDomainName GetNormalizedHostName(string name) } } } + + // + // See RFC 6761 for reserved DNS names. + // + private static ReservedNameType CheckIsReservedDnsName(string name) + { + ReadOnlySpan nameAsSpan = name; + nameAsSpan = nameAsSpan.TrimEnd('.'); // trim potential explicit root label + + if (MatchesReservedName(nameAsSpan, "localhost")) + { + return ReservedNameType.Localhost; + } + + if (MatchesReservedName(nameAsSpan, "invalid")) + { + return ReservedNameType.Invalid; + } + + return ReservedNameType.None; + + static bool MatchesReservedName(ReadOnlySpan name, string reservedName) + { + // check if equal to reserved name or is a subdomain of it + if (name.EndsWith(reservedName, StringComparison.OrdinalIgnoreCase) && + (name.Length == reservedName.Length || name[name.Length - reservedName.Length - 1] == '.')) + { + return true; + } + + return false; + } + } + + private enum ReservedNameType + { + None, + Localhost, + Invalid, + } } diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs index 27d1048e9f8..c780f357a04 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -50,6 +50,7 @@ internal static class Experiments // constants to manage which experiment each API belongs to. internal const string AIImageGeneration = AIExperiments; internal const string AISpeechToText = AIExperiments; + internal const string AITextToSpeech = AIExperiments; internal const string AIMcpServers = AIExperiments; internal const string AIFunctionApprovals = AIExperiments; 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 c847bad9c93..9fa61bf4f35 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs @@ -1095,6 +1095,51 @@ public async Task ToChatResponse_WebSearchToolCallContentWithDistinctCallIds_Doe Assert.Equal(3, webSearchCalls.Length); } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ToChatResponse_CoalescesWebSearchToolCallContent_CallingToChatResponseTwiceOnSameUpdates(bool useAsync, bool firstHasQueries) + { + object rawRepresentation = new(); + string[] expectedQueries = firstHasQueries ? ["first query", "dotnet extensions"] : ["dotnet extensions"]; + + var inProgress = new WebSearchToolCallContent("ws1") + { + RawRepresentation = rawRepresentation, + Queries = firstHasQueries ? ["first query"] : null, + }; + + ChatResponseUpdate[] updates = + { + new(null, "Searching the web..."), + + // In-progress: mirrors StreamingResponseWebSearchCallInProgressUpdate. + // With the OpenAI provider this has null Queries, but exercise both paths. + new() { Contents = [inProgress] }, + + // Done: queries populated (mirrors StreamingResponseOutputItemDoneUpdate with WebSearchCallResponseItem) + new() { Contents = [new WebSearchToolCallContent("ws1") { Queries = ["dotnet extensions"] }] }, + + // Function call in the same response (this is what triggers FunctionInvokingChatClient + // to call ToChatResponse internally before the caller does) + new() { Contents = [new FunctionCallContent("fc1", "SearchTool", new Dictionary { ["q"] = "test" })] }, + }; + + // First call — simulates FunctionInvokingChatClient's internal ToChatResponse. + ChatResponse response1 = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + var ws1First = Assert.Single(response1.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Equal(expectedQueries, ws1First.Queries); + Assert.Same(rawRepresentation, ws1First.RawRepresentation); + + // Second call — simulates the caller's ToChatResponse on the same updates. + ChatResponse response2 = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + var ws1Second = Assert.Single(response2.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Equal(expectedQueries, ws1Second.Queries); + Assert.Same(rawRepresentation, ws1Second.RawRepresentation); + } + private static async IAsyncEnumerable YieldAsync(IEnumerable updates) { foreach (ChatResponseUpdate update in updates) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UriContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UriContentTests.cs index c0a342471a6..a56a4d4dee2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UriContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UriContentTests.cs @@ -16,11 +16,9 @@ public void Ctor_InvalidUriMediaType_Throws() Assert.Throws("uri", () => new UriContent((Uri)null!, "image/png")); Assert.Throws(() => new UriContent("notauri", "image/png")); - Assert.Throws("mediaType", () => new UriContent("data:image/png;base64,aGVsbG8=", null!)); Assert.Throws("mediaType", () => new UriContent("data:image/png;base64,aGVsbG8=", "")); Assert.Throws("mediaType", () => new UriContent("data:image/png;base64,aGVsbG8=", "image")); - Assert.Throws("mediaType", () => new UriContent(new Uri("data:image/png;base64,aGVsbG8="), null!)); Assert.Throws("mediaType", () => new UriContent(new Uri("data:image/png;base64,aGVsbG8="), "")); Assert.Throws("mediaType", () => new UriContent(new Uri("data:image/png;base64,aGVsbG8="), "audio")); @@ -61,6 +59,98 @@ public void Ctor_ValidMediaType_Roundtrips(string mediaType) Assert.Equal(mediaType, content.MediaType); } + [Theory] + [InlineData("http://localhost/image.png", "image/png")] + [InlineData("http://localhost/audio.mp3", "audio/mpeg")] + [InlineData("http://localhost/document.pdf", "application/pdf")] + [InlineData("http://localhost/data.json", "application/json")] + [InlineData("http://localhost/page.html", "text/html")] + [InlineData("http://localhost/photo.jpg", "image/jpeg")] + [InlineData("http://localhost/path/to/file.wav", "audio/wav")] + [InlineData("http://localhost/path/to/file.svg", "image/svg+xml")] + [InlineData("http://localhost/image.png?width=100&height=100", "image/png")] + [InlineData("http://localhost/image.png#section", "image/png")] + [InlineData("http://localhost/image.png?q=1#frag", "image/png")] + public void Ctor_NullMediaType_InfersFromExtension_StringUri(string uri, string expectedMediaType) + { + var content = new UriContent(uri); + Assert.Equal(expectedMediaType, content.MediaType); + + var content2 = new UriContent(uri, null); + Assert.Equal(expectedMediaType, content2.MediaType); + } + + [Theory] + [InlineData("http://localhost/image.png", "image/png")] + [InlineData("http://localhost/audio.mp3", "audio/mpeg")] + [InlineData("http://localhost/document.pdf", "application/pdf")] + [InlineData("http://localhost/photo.jpg", "image/jpeg")] + [InlineData("http://localhost/image.png?width=100", "image/png")] + [InlineData("http://localhost/image.png#section", "image/png")] + [InlineData("http://localhost/image.png?q=1#frag", "image/png")] + public void Ctor_NullMediaType_InfersFromExtension_AbsoluteUri(string uri, string expectedMediaType) + { + var content = new UriContent(new Uri(uri)); + Assert.Equal(expectedMediaType, content.MediaType); + + var content2 = new UriContent(new Uri(uri), null); + Assert.Equal(expectedMediaType, content2.MediaType); + } + + [Theory] + [InlineData("image.png", "image/png")] + [InlineData("audio.mp3", "audio/mpeg")] + [InlineData("path/to/document.pdf", "application/pdf")] + [InlineData("photo.jpg", "image/jpeg")] + [InlineData("image.png?width=100", "image/png")] + [InlineData("image.png#section", "image/png")] + [InlineData("image.png?q=1#frag", "image/png")] + [InlineData("path/to/file.wav?key=value&other=123", "audio/wav")] + [InlineData("path/to/file.svg#top", "image/svg+xml")] + public void Ctor_NullMediaType_InfersFromExtension_RelativeUri(string uri, string expectedMediaType) + { + var content = new UriContent(new Uri(uri, UriKind.Relative)); + Assert.Equal(expectedMediaType, content.MediaType); + + var content2 = new UriContent(new Uri(uri, UriKind.Relative), null); + Assert.Equal(expectedMediaType, content2.MediaType); + } + + [Theory] + [InlineData("http://localhost/noextension")] + [InlineData("http://localhost/path/to/resource")] + [InlineData("http://localhost/")] + [InlineData("http://localhost/file.unknownext")] + [InlineData("http://localhost/file.xyz123")] + public void Ctor_NullMediaType_NoOrUnknownExtension_DefaultsToOctetStream(string uri) + { + var content = new UriContent(uri); + Assert.Equal("application/octet-stream", content.MediaType); + } + + [Theory] + [InlineData("noextension")] + [InlineData("path/to/resource")] + [InlineData("file.unknownext")] + [InlineData("file.xyz123")] + [InlineData("noext?q=1")] + [InlineData("noext#frag")] + public void Ctor_NullMediaType_RelativeUri_NoOrUnknownExtension_DefaultsToOctetStream(string uri) + { + var content = new UriContent(new Uri(uri, UriKind.Relative)); + Assert.Equal("application/octet-stream", content.MediaType); + } + + [Fact] + public void Ctor_ExplicitMediaType_OverridesInference() + { + var content = new UriContent("http://localhost/image.png", "application/octet-stream"); + Assert.Equal("application/octet-stream", content.MediaType); + + var relContent = new UriContent(new Uri("image.png", UriKind.Relative), "application/octet-stream"); + Assert.Equal("application/octet-stream", relContent.MediaType); + } + [Fact] public void Serialize_MatchesExpectedJson() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Files/HostedFileDownloadStreamTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Files/HostedFileDownloadStreamTests.cs index fad1a834374..dd32ec807a1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Files/HostedFileDownloadStreamTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Files/HostedFileDownloadStreamTests.cs @@ -50,6 +50,31 @@ public async Task ToDataContentAsync_EmptyStream_ReturnsEmptyData() Assert.Empty(content.Data.ToArray()); } + [Fact] + public void CanWrite_ReturnsFalse() + { + using var stream = new MinimalDownloadStream([1, 2, 3]); + Assert.False(stream.CanWrite); + } + + [Fact] + public async Task WriteApis_ThrowNotSupportedException() + { + using var stream = new MinimalDownloadStream([1, 2, 3]); + + Assert.Throws(() => stream.SetLength(1)); + Assert.Throws(() => stream.Write([4], 0, 1)); + Assert.Throws(() => stream.WriteByte(4)); + await Assert.ThrowsAsync(() => stream.WriteAsync(new byte[] { 4 }, 0, 1)); + Assert.Throws(() => stream.BeginWrite([4], 0, 1, callback: null, state: null)); + Assert.Throws(() => stream.EndWrite(Task.CompletedTask)); + +#if NET + Assert.Throws(() => stream.Write([4])); + await Assert.ThrowsAsync(() => stream.WriteAsync(new byte[] { 4 }).AsTask()); +#endif + } + /// /// Minimal implementation that does not override MediaType or FileName, testing the default behavior. /// @@ -64,14 +89,11 @@ public MinimalDownloadStream(byte[] data) public override bool CanRead => _inner.CanRead; public override bool CanSeek => _inner.CanSeek; - public override bool CanWrite => false; public override long Length => _inner.Length; public override long Position { get => _inner.Position; set => _inner.Position = value; } public override void Flush() => _inner.Flush(); public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); - public override void SetLength(long value) => _inner.SetLength(value); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); protected override void Dispose(bool disposing) { @@ -102,14 +124,11 @@ public MetadataDownloadStream(byte[] data, string? mediaType, string? fileName) public override string? FileName { get; } public override bool CanRead => _inner.CanRead; public override bool CanSeek => _inner.CanSeek; - public override bool CanWrite => false; public override long Length => _inner.Length; public override long Position { get => _inner.Position; set => _inner.Position = value; } public override void Flush() => _inner.Flush(); public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); - public override void SetLength(long value) => _inner.SetLength(value); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); protected override void Dispose(bool disposing) { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeAudioFormatTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeAudioFormatTests.cs new file mode 100644 index 00000000000..bea2c58449e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeAudioFormatTests.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + +namespace Microsoft.Extensions.AI; + +public class RealtimeAudioFormatTests +{ + [Fact] + public void Constructor_SetsProperties() + { + var format = new RealtimeAudioFormat("audio/pcm", 16000); + + Assert.Equal("audio/pcm", format.MediaType); + Assert.Equal(16000, format.SampleRate); + } + + [Fact] + public void Properties_Roundtrip() + { + var format = new RealtimeAudioFormat("audio/pcm", 16000) + { + MediaType = "audio/wav", + SampleRate = 24000, + }; + + Assert.Equal("audio/wav", format.MediaType); + Assert.Equal(24000, format.SampleRate); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeClientMessageTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeClientMessageTests.cs new file mode 100644 index 00000000000..0ca99aa3495 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeClientMessageTests.cs @@ -0,0 +1,183 @@ +// 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 Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + +namespace Microsoft.Extensions.AI; + +public class RealtimeClientMessageTests +{ + [Fact] + public void RealtimeClientMessage_DefaultProperties() + { + var message = new RealtimeClientMessage(); + + Assert.Null(message.MessageId); + Assert.Null(message.RawRepresentation); + } + + [Fact] + public void RealtimeClientMessage_Properties_Roundtrip() + { + var rawObj = new object(); + var message = new RealtimeClientMessage + { + MessageId = "evt_001", + RawRepresentation = rawObj, + }; + + Assert.Equal("evt_001", message.MessageId); + Assert.Same(rawObj, message.RawRepresentation); + } + + [Fact] + public void ConversationItemCreateMessage_Constructor_SetsProperties() + { + var contents = new List { new TextContent("Hello") }; + var item = new RealtimeConversationItem(contents, "item_1", ChatRole.User); + + var message = new CreateConversationItemRealtimeClientMessage(item); + + Assert.Same(item, message.Item); + } + + [Fact] + public void ConversationItemCreateMessage_Properties_Roundtrip() + { + var item = new RealtimeConversationItem([new TextContent("Hello")]); + var message = new CreateConversationItemRealtimeClientMessage(item); + + var newItem = new RealtimeConversationItem([new TextContent("World")]); + message.Item = newItem; + + Assert.Same(newItem, message.Item); + } + + [Fact] + public void ConversationItemCreateMessage_InheritsClientMessage() + { + var item = new RealtimeConversationItem([new TextContent("Hello")]); + var message = new CreateConversationItemRealtimeClientMessage(item) + { + MessageId = "evt_create_1", + }; + + Assert.Equal("evt_create_1", message.MessageId); + Assert.IsAssignableFrom(message); + } + + [Fact] + public void InputAudioBufferAppendMessage_Constructor_SetsContent() + { + var audioContent = new DataContent(new byte[] { 1, 2, 3 }, "audio/pcm"); + var message = new InputAudioBufferAppendRealtimeClientMessage(audioContent); + + Assert.Same(audioContent, message.Content); + } + + [Fact] + public void InputAudioBufferAppendMessage_Properties_Roundtrip() + { + var audioContent = new DataContent(new byte[] { 1, 2, 3 }, "audio/pcm"); + var message = new InputAudioBufferAppendRealtimeClientMessage(audioContent); + + var newContent = new DataContent(new byte[] { 4, 5, 6 }, "audio/wav"); + message.Content = newContent; + + Assert.Same(newContent, message.Content); + } + + [Fact] + public void InputAudioBufferAppendMessage_InheritsClientMessage() + { + var audioContent = new DataContent(new byte[] { 1, 2, 3 }, "audio/pcm"); + var message = new InputAudioBufferAppendRealtimeClientMessage(audioContent) + { + MessageId = "evt_append_1", + }; + + Assert.Equal("evt_append_1", message.MessageId); + Assert.IsAssignableFrom(message); + } + + [Fact] + public void InputAudioBufferCommitMessage_Constructor() + { + var message = new InputAudioBufferCommitRealtimeClientMessage(); + + Assert.IsAssignableFrom(message); + Assert.Null(message.MessageId); + } + + [Fact] + public void ResponseCreateMessage_DefaultProperties() + { + var message = new CreateResponseRealtimeClientMessage(); + + Assert.Null(message.Items); + Assert.Null(message.OutputAudioOptions); + Assert.Null(message.OutputVoice); + Assert.Null(message.ExcludeFromConversation); + Assert.Null(message.Instructions); + Assert.Null(message.MaxOutputTokens); + Assert.Null(message.AdditionalProperties); + Assert.Null(message.OutputModalities); + Assert.Null(message.ToolMode); + Assert.Null(message.Tools); + } + + [Fact] + public void ResponseCreateMessage_Properties_Roundtrip() + { + var message = new CreateResponseRealtimeClientMessage(); + + var items = new List + { + new RealtimeConversationItem([new TextContent("Hello")], "item_1", ChatRole.User), + }; + var audioFormat = new RealtimeAudioFormat("audio/pcm", 16000); + var modalities = new List { "text", "audio" }; + var tools = new List { AIFunctionFactory.Create(() => 42) }; + var metadata = new AdditionalPropertiesDictionary { ["key"] = "value" }; + + message.Items = items; + message.OutputAudioOptions = audioFormat; + message.OutputVoice = "alloy"; + message.ExcludeFromConversation = true; + message.Instructions = "Be brief"; + message.MaxOutputTokens = 100; + message.AdditionalProperties = metadata; + message.OutputModalities = modalities; + message.ToolMode = ChatToolMode.Auto; + message.Tools = tools; + + Assert.Same(items, message.Items); + Assert.Same(audioFormat, message.OutputAudioOptions); + Assert.Equal("alloy", message.OutputVoice); + Assert.True(message.ExcludeFromConversation); + Assert.Equal("Be brief", message.Instructions); + Assert.Equal(100, message.MaxOutputTokens); + Assert.Same(metadata, message.AdditionalProperties); + Assert.Same(modalities, message.OutputModalities); + Assert.Equal(ChatToolMode.Auto, message.ToolMode); + Assert.Same(tools, message.Tools); + } + + [Fact] + public void ResponseCreateMessage_InheritsClientMessage() + { + var message = new CreateResponseRealtimeClientMessage + { + MessageId = "evt_resp_1", + RawRepresentation = "raw", + }; + + Assert.Equal("evt_resp_1", message.MessageId); + Assert.Equal("raw", message.RawRepresentation); + Assert.IsAssignableFrom(message); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeConversationItemTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeConversationItemTests.cs new file mode 100644 index 00000000000..0beae48edf0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeConversationItemTests.cs @@ -0,0 +1,66 @@ +// 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 Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + +namespace Microsoft.Extensions.AI; + +public class RealtimeConversationItemTests +{ + [Fact] + public void Constructor_WithContentsOnly_PropsDefaulted() + { + IList contents = [new TextContent("Hello")]; + var item = new RealtimeConversationItem(contents); + + Assert.Same(contents, item.Contents); + Assert.Null(item.Id); + Assert.Null(item.Role); + Assert.Null(item.RawRepresentation); + } + + [Fact] + public void Constructor_WithAllArgs_PropsRoundtrip() + { + IList contents = [new TextContent("Hello"), new TextContent("World")]; + var item = new RealtimeConversationItem(contents, "item_123", ChatRole.User); + + Assert.Same(contents, item.Contents); + Assert.Equal("item_123", item.Id); + Assert.Equal(ChatRole.User, item.Role); + } + + [Fact] + public void Properties_Roundtrip() + { + IList contents = [new TextContent("Initial")]; + var item = new RealtimeConversationItem(contents); + + IList newContents = [new TextContent("Updated")]; + item.Id = "new_id"; + item.Role = ChatRole.Assistant; + item.Contents = newContents; + item.RawRepresentation = "raw_data"; + + Assert.Equal("new_id", item.Id); + Assert.Equal(ChatRole.Assistant, item.Role); + Assert.Same(newContents, item.Contents); + Assert.Equal("raw_data", item.RawRepresentation); + } + + [Fact] + public void Constructor_WithFunctionContent_NoIdOrRole() + { + var functionCall = new FunctionCallContent("call_1", "myFunc"); + IList contents = [functionCall]; + var item = new RealtimeConversationItem(contents); + + Assert.Null(item.Id); + Assert.Null(item.Role); + Assert.Single(item.Contents); + Assert.IsType(item.Contents[0]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeServerMessageTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeServerMessageTests.cs new file mode 100644 index 00000000000..caa1c0d80b6 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeServerMessageTests.cs @@ -0,0 +1,262 @@ +// 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 Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + +namespace Microsoft.Extensions.AI; + +public class RealtimeServerMessageTests +{ + [Fact] + public void RealtimeServerMessage_DefaultProperties() + { + var message = new RealtimeServerMessage(); + + Assert.Equal(default, message.Type); + Assert.Null(message.MessageId); + Assert.Null(message.RawRepresentation); + } + + [Fact] + public void RealtimeServerMessage_Properties_Roundtrip() + { + var rawObj = new object(); + var message = new RealtimeServerMessage + { + Type = RealtimeServerMessageType.ResponseDone, + MessageId = "evt_001", + RawRepresentation = rawObj, + }; + + Assert.Equal(RealtimeServerMessageType.ResponseDone, message.Type); + Assert.Equal("evt_001", message.MessageId); + Assert.Same(rawObj, message.RawRepresentation); + } + + [Fact] + public void ErrorMessage_Constructor_SetsType() + { + var message = new ErrorRealtimeServerMessage(); + + Assert.Equal(RealtimeServerMessageType.Error, message.Type); + } + + [Fact] + public void ErrorMessage_DefaultProperties() + { + var message = new ErrorRealtimeServerMessage(); + + Assert.Null(message.Error); + Assert.Null(message.OriginatingMessageId); + } + + [Fact] + public void ErrorMessage_Properties_Roundtrip() + { + var error = new ErrorContent("Test error") { Details = "temperature" }; + var message = new ErrorRealtimeServerMessage + { + Error = error, + OriginatingMessageId = "evt_bad", + MessageId = "evt_err_1", + }; + + Assert.Same(error, message.Error); + Assert.Equal("evt_bad", message.OriginatingMessageId); + Assert.Equal("temperature", message.Error.Details); + Assert.Equal("evt_err_1", message.MessageId); + Assert.IsAssignableFrom(message); + } + + [Fact] + public void InputAudioTranscriptionMessage_Constructor_SetsType() + { + var message = new InputAudioTranscriptionRealtimeServerMessage( + RealtimeServerMessageType.InputAudioTranscriptionCompleted); + + Assert.Equal(RealtimeServerMessageType.InputAudioTranscriptionCompleted, message.Type); + } + + [Fact] + public void InputAudioTranscriptionMessage_DefaultProperties() + { + var message = new InputAudioTranscriptionRealtimeServerMessage( + RealtimeServerMessageType.InputAudioTranscriptionDelta); + + Assert.Null(message.ContentIndex); + Assert.Null(message.ItemId); + Assert.Null(message.Transcription); + Assert.Null(message.Usage); + Assert.Null(message.Error); + } + + [Fact] + public void InputAudioTranscriptionMessage_Properties_Roundtrip() + { + var usage = new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20 }; + var error = new ErrorContent("transcription error"); + + var message = new InputAudioTranscriptionRealtimeServerMessage( + RealtimeServerMessageType.InputAudioTranscriptionCompleted) + { + ContentIndex = 0, + ItemId = "item_audio_1", + Transcription = "Hello world", + Usage = usage, + Error = error, + }; + + Assert.Equal(0, message.ContentIndex); + Assert.Equal("item_audio_1", message.ItemId); + Assert.Equal("Hello world", message.Transcription); + Assert.Same(usage, message.Usage); + Assert.Same(error, message.Error); + Assert.IsAssignableFrom(message); + } + + [Fact] + public void OutputTextAudioMessage_Constructor_SetsType() + { + var message = new OutputTextAudioRealtimeServerMessage(RealtimeServerMessageType.OutputTextDelta); + + Assert.Equal(RealtimeServerMessageType.OutputTextDelta, message.Type); + } + + [Fact] + public void OutputTextAudioMessage_DefaultProperties() + { + var message = new OutputTextAudioRealtimeServerMessage(RealtimeServerMessageType.OutputTextDelta); + + Assert.Null(message.ContentIndex); + Assert.Null(message.Text); + Assert.Null(message.Audio); + Assert.Null(message.ItemId); + Assert.Null(message.OutputIndex); + Assert.Null(message.ResponseId); + } + + [Fact] + public void OutputTextAudioMessage_Properties_Roundtrip() + { + var message = new OutputTextAudioRealtimeServerMessage(RealtimeServerMessageType.OutputTextDone) + { + ContentIndex = 0, + Text = "Hello there!", + ItemId = "item_text_1", + OutputIndex = 0, + ResponseId = "resp_1", + }; + + Assert.Equal(RealtimeServerMessageType.OutputTextDone, message.Type); + Assert.Equal(0, message.ContentIndex); + Assert.Equal("Hello there!", message.Text); + Assert.Equal("item_text_1", message.ItemId); + Assert.Equal(0, message.OutputIndex); + Assert.Equal("resp_1", message.ResponseId); + Assert.IsAssignableFrom(message); + } + + [Fact] + public void ResponseCreatedMessage_Constructor_SetsType() + { + var message = new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseCreated); + + Assert.Equal(RealtimeServerMessageType.ResponseCreated, message.Type); + } + + [Fact] + public void ResponseCreatedMessage_DefaultProperties() + { + var message = new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone); + + Assert.Null(message.OutputAudioOptions); + Assert.Null(message.OutputVoice); + Assert.Null(message.ResponseId); + Assert.Null(message.MaxOutputTokens); + Assert.Null(message.AdditionalProperties); + Assert.Null(message.Items); + Assert.Null(message.OutputModalities); + Assert.Null(message.Status); + Assert.Null(message.Error); + Assert.Null(message.Usage); + } + + [Fact] + public void ResponseCreatedMessage_Properties_Roundtrip() + { + var audioFormat = new RealtimeAudioFormat("audio/pcm", 24000); + var metadata = new AdditionalPropertiesDictionary { ["key"] = "value" }; + var items = new List + { + new RealtimeConversationItem([new TextContent("response")], "item_1"), + }; + var modalities = new List { "text" }; + var error = new ErrorContent("response error"); + var usage = new UsageDetails { InputTokenCount = 15, OutputTokenCount = 25, TotalTokenCount = 40 }; + + var message = new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone) + { + OutputAudioOptions = audioFormat, + OutputVoice = "alloy", + ResponseId = "resp_1", + MaxOutputTokens = 1000, + AdditionalProperties = metadata, + Items = items, + OutputModalities = modalities, + Status = "completed", + Error = error, + Usage = usage, + }; + + Assert.Same(audioFormat, message.OutputAudioOptions); + Assert.Equal("alloy", message.OutputVoice); + Assert.Equal("resp_1", message.ResponseId); + Assert.Equal(1000, message.MaxOutputTokens); + Assert.Same(metadata, message.AdditionalProperties); + Assert.Same(items, message.Items); + Assert.Same(modalities, message.OutputModalities); + Assert.Equal("completed", message.Status); + Assert.Same(error, message.Error); + Assert.Same(usage, message.Usage); + Assert.IsAssignableFrom(message); + } + + [Fact] + public void ResponseOutputItemMessage_Constructor_SetsType() + { + var message = new ResponseOutputItemRealtimeServerMessage(RealtimeServerMessageType.ResponseOutputItemDone); + + Assert.Equal(RealtimeServerMessageType.ResponseOutputItemDone, message.Type); + } + + [Fact] + public void ResponseOutputItemMessage_DefaultProperties() + { + var message = new ResponseOutputItemRealtimeServerMessage(RealtimeServerMessageType.ResponseOutputItemAdded); + + Assert.Null(message.ResponseId); + Assert.Null(message.OutputIndex); + Assert.Null(message.Item); + } + + [Fact] + public void ResponseOutputItemMessage_Properties_Roundtrip() + { + var item = new RealtimeConversationItem([new TextContent("output")], "item_out_1", ChatRole.Assistant); + + var message = new ResponseOutputItemRealtimeServerMessage(RealtimeServerMessageType.ResponseOutputItemDone) + { + ResponseId = "resp_1", + OutputIndex = 0, + Item = item, + }; + + Assert.Equal("resp_1", message.ResponseId); + Assert.Equal(0, message.OutputIndex); + Assert.Same(item, message.Item); + Assert.IsAssignableFrom(message); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeSessionOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeSessionOptionsTests.cs new file mode 100644 index 00000000000..c60f77875e4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Realtime/RealtimeSessionOptionsTests.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 Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + +namespace Microsoft.Extensions.AI; + +public class RealtimeSessionOptionsTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + RealtimeSessionOptions options = new(); + + Assert.Equal(RealtimeSessionKind.Conversation, options.SessionKind); + Assert.Null(options.Model); + Assert.Null(options.InputAudioFormat); + Assert.Null(options.TranscriptionOptions); + Assert.Null(options.OutputAudioFormat); + Assert.Null(options.Voice); + Assert.Null(options.Instructions); + Assert.Null(options.MaxOutputTokens); + Assert.Null(options.OutputModalities); + Assert.Null(options.ToolMode); + Assert.Null(options.Tools); + Assert.Null(options.VoiceActivityDetection); + } + + [Fact] + public void Properties_Roundtrip() + { + var inputFormat = new RealtimeAudioFormat("audio/pcm", 16000); + var outputFormat = new RealtimeAudioFormat("audio/pcm", 24000); + List modalities = ["text", "audio"]; + List tools = [AIFunctionFactory.Create(() => 42)]; + var transcriptionOptions = new TranscriptionOptions { SpeechLanguage = "en", ModelId = "whisper-1", Prompt = "greeting" }; + + RealtimeSessionOptions options = new() + { + SessionKind = RealtimeSessionKind.Transcription, + Model = "gpt-4-realtime", + InputAudioFormat = inputFormat, + OutputAudioFormat = outputFormat, + TranscriptionOptions = transcriptionOptions, + Voice = "alloy", + Instructions = "Be helpful", + MaxOutputTokens = 500, + OutputModalities = modalities, + ToolMode = ChatToolMode.Auto, + Tools = tools, + }; + + Assert.Equal(RealtimeSessionKind.Transcription, options.SessionKind); + Assert.Equal("gpt-4-realtime", options.Model); + Assert.Same(inputFormat, options.InputAudioFormat); + Assert.Same(outputFormat, options.OutputAudioFormat); + Assert.Same(transcriptionOptions, options.TranscriptionOptions); + Assert.Equal("alloy", options.Voice); + Assert.Equal("Be helpful", options.Instructions); + Assert.Equal(500, options.MaxOutputTokens); + Assert.Same(modalities, options.OutputModalities); + Assert.Equal(ChatToolMode.Auto, options.ToolMode); + Assert.Same(tools, options.Tools); + } + + [Fact] + public void TranscriptionOptions_Properties_Roundtrip() + { + var options = new TranscriptionOptions { SpeechLanguage = "en", ModelId = "whisper-1", Prompt = "greeting" }; + + Assert.Equal("en", options.SpeechLanguage); + Assert.Equal("whisper-1", options.ModelId); + Assert.Equal("greeting", options.Prompt); + + options.SpeechLanguage = "fr"; + options.ModelId = "whisper-2"; + options.Prompt = null; + + Assert.Equal("fr", options.SpeechLanguage); + Assert.Equal("whisper-2", options.ModelId); + Assert.Null(options.Prompt); + } + + [Fact] + public void TranscriptionOptions_PromptDefaultsToNull() + { + var options = new TranscriptionOptions { SpeechLanguage = "en", ModelId = "whisper-1" }; + Assert.Null(options.Prompt); + } + + [Fact] + public void VoiceActivityDetection_DefaultsToNull() + { + RealtimeSessionOptions options = new(); + Assert.Null(options.VoiceActivityDetection); + } + + [Fact] + public void VoiceActivityDetection_Roundtrip() + { + var vad = new VoiceActivityDetectionOptions(); + RealtimeSessionOptions options = new() + { + VoiceActivityDetection = vad, + }; + + Assert.Same(vad, options.VoiceActivityDetection); + } + + [Fact] + public void VoiceActivityDetectionOptions_DefaultValues() + { + var vad = new VoiceActivityDetectionOptions(); + Assert.True(vad.Enabled); + Assert.True(vad.AllowInterruption); + } + + [Fact] + public void VoiceActivityDetectionOptions_Properties_Roundtrip() + { + var vad = new VoiceActivityDetectionOptions + { + Enabled = false, + AllowInterruption = false, + }; + + Assert.False(vad.Enabled); + Assert.False(vad.AllowInterruption); + + vad.Enabled = true; + vad.AllowInterruption = true; + + Assert.True(vad.Enabled); + Assert.True(vad.AllowInterruption); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index b817cf7fd54..c5c111a8aa2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -20,6 +20,10 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(SpeechToTextResponseUpdate))] [JsonSerializable(typeof(SpeechToTextResponseUpdateKind))] [JsonSerializable(typeof(SpeechToTextOptions))] +[JsonSerializable(typeof(TextToSpeechResponse))] +[JsonSerializable(typeof(TextToSpeechResponseUpdate))] +[JsonSerializable(typeof(TextToSpeechResponseUpdateKind))] +[JsonSerializable(typeof(TextToSpeechOptions))] [JsonSerializable(typeof(ImageGenerationResponse))] [JsonSerializable(typeof(ImageGenerationOptions))] [JsonSerializable(typeof(ChatOptions))] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestRealtimeClientSession.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestRealtimeClientSession.cs new file mode 100644 index 00000000000..a7bbb399c8b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestRealtimeClientSession.cs @@ -0,0 +1,62 @@ +// 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; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// A test implementation that uses callbacks for verification. +public sealed class TestRealtimeClientSession : IRealtimeClientSession +{ + /// Gets or sets the callback to invoke when is called. + public Func? SendAsyncCallback { get; set; } + + /// Gets or sets the callback to invoke when is called. + public Func>? GetStreamingResponseAsyncCallback { get; set; } + + /// Gets or sets the callback to invoke when is called. + public Func? GetServiceCallback { get; set; } + + /// + public RealtimeSessionOptions? Options { get; set; } + + /// + public Task SendAsync(RealtimeClientMessage message, CancellationToken cancellationToken = default) + { + return SendAsyncCallback?.Invoke(message, cancellationToken) ?? Task.CompletedTask; + } + + /// + public IAsyncEnumerable GetStreamingResponseAsync( + CancellationToken cancellationToken = default) + { + return GetStreamingResponseAsyncCallback?.Invoke(cancellationToken) ?? EmptyAsyncEnumerable(); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + if (GetServiceCallback is { } callback) + { + return callback(serviceType, serviceKey); + } + + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + /// + public ValueTask DisposeAsync() + { + // No-op for test implementation + return default; + } + + private static async IAsyncEnumerable EmptyAsyncEnumerable() + { + await Task.CompletedTask.ConfigureAwait(false); + yield break; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestTextToSpeechClient.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestTextToSpeechClient.cs new file mode 100644 index 00000000000..8f6f1022563 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestTextToSpeechClient.cs @@ -0,0 +1,59 @@ +// 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; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +public sealed class TestTextToSpeechClient : ITextToSpeechClient +{ + public TestTextToSpeechClient() + { + GetServiceCallback = DefaultGetServiceCallback; + } + + public IServiceProvider? Services { get; set; } + + // Callbacks for asynchronous operations. + public Func>? + GetAudioAsyncCallback + { get; set; } + + public Func>? + GetStreamingAudioAsyncCallback + { get; set; } + + public Func GetServiceCallback { get; set; } + + private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) + => serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public Task GetAudioAsync( + string text, + TextToSpeechOptions? options = null, + CancellationToken cancellationToken = default) + => GetAudioAsyncCallback!.Invoke(text, options, cancellationToken); + + public IAsyncEnumerable GetStreamingAudioAsync( + string text, + TextToSpeechOptions? options = null, + CancellationToken cancellationToken = default) + => GetStreamingAudioAsyncCallback!.Invoke(text, options, cancellationToken); + + public object? GetService(Type serviceType, object? serviceKey = null) + => GetServiceCallback!.Invoke(serviceType, serviceKey); + + public void Dispose() + { + // Dispose of resources if any. + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/DelegatingTextToSpeechClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/DelegatingTextToSpeechClientTests.cs new file mode 100644 index 00000000000..5ff0d1a474e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/DelegatingTextToSpeechClientTests.cs @@ -0,0 +1,163 @@ +// 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; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DelegatingTextToSpeechClientTests +{ + [Fact] + public void RequiresInnerTextToSpeechClient() + { + Assert.Throws("innerClient", () => new NoOpDelegatingTextToSpeechClient(null!)); + } + + [Fact] + public async Task GetAudioAsyncDefaultsToInnerClientAsync() + { + // Arrange + var expectedText = "Hello, world!"; + var expectedOptions = new TextToSpeechOptions(); + var expectedCancellationToken = CancellationToken.None; + var expectedResult = new TaskCompletionSource(); + var expectedResponse = new TextToSpeechResponse([]); + using var inner = new TestTextToSpeechClient + { + GetAudioAsyncCallback = (text, options, cancellationToken) => + { + Assert.Equal(expectedText, text); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCancellationToken, cancellationToken); + return expectedResult.Task; + } + }; + + using var delegating = new NoOpDelegatingTextToSpeechClient(inner); + + // Act + var resultTask = delegating.GetAudioAsync(expectedText, expectedOptions, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(expectedResponse); + Assert.True(resultTask.IsCompleted); + Assert.Same(expectedResponse, await resultTask); + } + + [Fact] + public async Task GetStreamingAudioAsyncDefaultsToInnerClientAsync() + { + // Arrange + var expectedText = "Hello, world!"; + var expectedOptions = new TextToSpeechOptions(); + var expectedCancellationToken = CancellationToken.None; + TextToSpeechResponseUpdate[] expectedResults = + [ + new([new DataContent(new byte[] { 1, 2 }, "audio/mpeg")]), + new([new DataContent(new byte[] { 3, 4 }, "audio/mpeg")]) + ]; + + using var inner = new TestTextToSpeechClient + { + GetStreamingAudioAsyncCallback = (text, options, cancellationToken) => + { + Assert.Equal(expectedText, text); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCancellationToken, cancellationToken); + return YieldAsync(expectedResults); + } + }; + + using var delegating = new NoOpDelegatingTextToSpeechClient(inner); + + // Act + var resultAsyncEnumerable = delegating.GetStreamingAudioAsync(expectedText, expectedOptions, expectedCancellationToken); + + // Assert + var enumerator = resultAsyncEnumerable.GetAsyncEnumerator(); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Same(expectedResults[0], enumerator.Current); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Same(expectedResults[1], enumerator.Current); + Assert.False(await enumerator.MoveNextAsync()); + } + + [Fact] + public void GetServiceThrowsForNullType() + { + using var inner = new TestTextToSpeechClient(); + using var delegating = new NoOpDelegatingTextToSpeechClient(inner); + Assert.Throws("serviceType", () => delegating.GetService(null!)); + } + + [Fact] + public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() + { + // Arrange + using var inner = new TestTextToSpeechClient(); + using var delegating = new NoOpDelegatingTextToSpeechClient(inner); + + // Act + var client = delegating.GetService(); + + // Assert + Assert.Same(delegating, client); + } + + [Fact] + public void GetServiceDelegatesToInnerIfKeyIsNotNull() + { + // Arrange + var expectedKey = new object(); + using var expectedResult = new TestTextToSpeechClient(); + using var inner = new TestTextToSpeechClient + { + GetServiceCallback = (_, _) => expectedResult + }; + using var delegating = new NoOpDelegatingTextToSpeechClient(inner); + + // Act + var client = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, client); + } + + [Fact] + public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest() + { + // Arrange + var expectedResult = TimeZoneInfo.Local; + var expectedKey = new object(); + using var inner = new TestTextToSpeechClient + { + GetServiceCallback = (type, key) => type == expectedResult.GetType() && key == expectedKey + ? expectedResult + : throw new InvalidOperationException("Unexpected call") + }; + using var delegating = new NoOpDelegatingTextToSpeechClient(inner); + + // Act + var tzi = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, tzi); + } + + private static async IAsyncEnumerable YieldAsync(IEnumerable input) + { + await Task.Yield(); + foreach (var item in input) + { + yield return item; + } + } + + private sealed class NoOpDelegatingTextToSpeechClient(ITextToSpeechClient innerClient) + : DelegatingTextToSpeechClient(innerClient); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientExtensionsTests.cs new file mode 100644 index 00000000000..9570d64f671 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientExtensionsTests.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 Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextToSpeechClientExtensionsTests +{ + [Fact] + public void GetService_InvalidArgs_Throws() + { + Assert.Throws("client", () => + { + _ = TextToSpeechClientExtensions.GetService(null!); + }); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientMetadataTests.cs new file mode 100644 index 00000000000..5c2313c6f55 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientMetadataTests.cs @@ -0,0 +1,29 @@ +// 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 Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextToSpeechClientMetadataTests +{ + [Fact] + public void Constructor_NullValues_AllowedAndRoundtrip() + { + TextToSpeechClientMetadata metadata = new(null, null, null); + Assert.Null(metadata.ProviderName); + Assert.Null(metadata.ProviderUri); + Assert.Null(metadata.DefaultModelId); + } + + [Fact] + public void Constructor_Value_Roundtrips() + { + var uri = new Uri("https://example.com"); + TextToSpeechClientMetadata metadata = new("providerName", uri, "theModel"); + Assert.Equal("providerName", metadata.ProviderName); + Assert.Same(uri, metadata.ProviderUri); + Assert.Equal("theModel", metadata.DefaultModelId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientTests.cs new file mode 100644 index 00000000000..c2621402c4e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechClientTests.cs @@ -0,0 +1,72 @@ +// 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.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextToSpeechClientTests +{ + [Fact] + public async Task GetAudioAsync_CreatesAudioResponseAsync() + { + // Arrange + var expectedOptions = new TextToSpeechOptions(); + using var cts = new CancellationTokenSource(); + + using TestTextToSpeechClient client = new() + { + GetAudioAsyncCallback = (text, options, cancellationToken) => + { + return Task.FromResult(new TextToSpeechResponse([new DataContent(new byte[] { 1, 2, 3 }, "audio/mpeg")])); + }, + }; + + // Act + TextToSpeechResponse response = await client.GetAudioAsync("Hello, world!", expectedOptions, cts.Token); + + // Assert + Assert.Single(response.Contents); + Assert.IsType(response.Contents[0]); + Assert.Equal("audio/mpeg", ((DataContent)response.Contents[0]).MediaType); + } + + [Fact] + public async Task GetStreamingAudioAsync_CreatesStreamingUpdatesAsync() + { + // Arrange + var expectedOptions = new TextToSpeechOptions(); + using var cts = new CancellationTokenSource(); + + using TestTextToSpeechClient client = new() + { + GetStreamingAudioAsyncCallback = (text, options, cancellationToken) => + { + return GetStreamingUpdatesAsync(); + }, + }; + + // Act + List updates = []; + await foreach (var update in client.GetStreamingAudioAsync("Hello!", expectedOptions, cts.Token)) + { + updates.Add(update); + } + + // Assert + Assert.Equal(3, updates.Count); + Assert.IsType(updates[0].Contents[0]); + Assert.IsType(updates[1].Contents[0]); + Assert.IsType(updates[2].Contents[0]); + } + + private static async IAsyncEnumerable GetStreamingUpdatesAsync() + { + yield return new([new DataContent(new byte[] { 1 }, "audio/mpeg")]); + yield return new([new DataContent(new byte[] { 2 }, "audio/mpeg")]); + yield return new([new DataContent(new byte[] { 3 }, "audio/mpeg")]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechOptionsTests.cs new file mode 100644 index 00000000000..54a79a5eb75 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechOptionsTests.cs @@ -0,0 +1,216 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextToSpeechOptionsTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + TextToSpeechOptions options = new(); + Assert.Null(options.ModelId); + Assert.Null(options.VoiceId); + Assert.Null(options.Language); + Assert.Null(options.AudioFormat); + Assert.Null(options.Speed); + Assert.Null(options.Pitch); + Assert.Null(options.Volume); + Assert.Null(options.AdditionalProperties); + + TextToSpeechOptions clone = options.Clone(); + Assert.Null(clone.ModelId); + Assert.Null(clone.VoiceId); + Assert.Null(clone.Language); + Assert.Null(clone.AudioFormat); + Assert.Null(clone.Speed); + Assert.Null(clone.Pitch); + Assert.Null(clone.Volume); + Assert.Null(clone.AdditionalProperties); + } + + [Fact] + public void Properties_Roundtrip() + { + TextToSpeechOptions options = new(); + + AdditionalPropertiesDictionary additionalProps = new() + { + ["key"] = "value", + }; + + Func rawRepresentationFactory = (c) => null; + + options.ModelId = "modelId"; + options.VoiceId = "alloy"; + options.Language = "en-US"; + options.AudioFormat = "audio/mpeg"; + options.Speed = 1.5f; + options.Pitch = 0.8f; + options.Volume = 0.9f; + options.AdditionalProperties = additionalProps; + options.RawRepresentationFactory = rawRepresentationFactory; + + Assert.Equal("modelId", options.ModelId); + Assert.Equal("alloy", options.VoiceId); + Assert.Equal("en-US", options.Language); + Assert.Equal("audio/mpeg", options.AudioFormat); + Assert.Equal(1.5f, options.Speed); + Assert.Equal(0.8f, options.Pitch); + Assert.Equal(0.9f, options.Volume); + Assert.Same(additionalProps, options.AdditionalProperties); + Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); + + TextToSpeechOptions clone = options.Clone(); + Assert.Equal("modelId", clone.ModelId); + Assert.Equal("alloy", clone.VoiceId); + Assert.Equal("en-US", clone.Language); + Assert.Equal("audio/mpeg", clone.AudioFormat); + Assert.Equal(1.5f, clone.Speed); + Assert.Equal(0.8f, clone.Pitch); + Assert.Equal(0.9f, clone.Volume); + Assert.Equal(additionalProps, clone.AdditionalProperties); + Assert.Same(rawRepresentationFactory, clone.RawRepresentationFactory); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + TextToSpeechOptions options = new(); + + AdditionalPropertiesDictionary additionalProps = new() + { + ["key"] = "value", + }; + + options.ModelId = "modelId"; + options.VoiceId = "alloy"; + options.Language = "en-US"; + options.AudioFormat = "mp3"; + options.Speed = 1.5f; + options.Pitch = 0.8f; + options.Volume = 0.9f; + options.AdditionalProperties = additionalProps; + + string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.TextToSpeechOptions); + + TextToSpeechOptions? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.TextToSpeechOptions); + Assert.NotNull(deserialized); + + Assert.Equal("modelId", deserialized.ModelId); + Assert.Equal("alloy", deserialized.VoiceId); + Assert.Equal("en-US", deserialized.Language); + Assert.Equal("mp3", deserialized.AudioFormat); + Assert.Equal(1.5f, deserialized.Speed); + Assert.Equal(0.8f, deserialized.Pitch); + Assert.Equal(0.9f, deserialized.Volume); + + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Single(deserialized.AdditionalProperties); + Assert.True(deserialized.AdditionalProperties.TryGetValue("key", out object? value)); + Assert.IsType(value); + Assert.Equal("value", ((JsonElement)value!).GetString()); + } + + [Fact] + public void CopyConstructors_EnableHierarchyCloning() + { + OptionsB b = new() + { + ModelId = "test", + A = 42, + B = 84, + }; + + TextToSpeechOptions clone = b.Clone(); + + Assert.Equal("test", clone.ModelId); + Assert.Equal(42, Assert.IsType(clone, exactMatch: false).A); + Assert.Equal(84, Assert.IsType(clone, exactMatch: true).B); + } + + private class OptionsA : TextToSpeechOptions + { + public OptionsA() + { + } + + protected OptionsA(OptionsA other) + : base(other) + { + A = other.A; + } + + public int A { get; set; } + + public override TextToSpeechOptions Clone() => new OptionsA(this); + } + + private class OptionsB : OptionsA + { + public OptionsB() + { + } + + protected OptionsB(OptionsB other) + : base(other) + { + B = other.B; + } + + public int B { get; set; } + + public override TextToSpeechOptions Clone() => new OptionsB(this); + } + + [Fact] + public void CopyConstructor_Null_Valid() + { + PassedNullToBaseOptions options = new(); + Assert.NotNull(options); + } + + private class PassedNullToBaseOptions : TextToSpeechOptions + { + public PassedNullToBaseOptions() + : base(null) + { + } + } + + [Fact] + public void JsonDeserialization_KnownPayload() + { + const string Json = """ + { + "modelId": "tts-1", + "voiceId": "alloy", + "language": "en-US", + "audioFormat": "mp3", + "speed": 1.5, + "pitch": 0.8, + "volume": 0.9, + "additionalProperties": { + "key": "val" + } + } + """; + + TextToSpeechOptions? result = JsonSerializer.Deserialize(Json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(result); + Assert.Equal("tts-1", result.ModelId); + Assert.Equal("alloy", result.VoiceId); + Assert.Equal("en-US", result.Language); + Assert.Equal("mp3", result.AudioFormat); + Assert.Equal(1.5f, result.Speed); + Assert.Equal(0.8f, result.Pitch); + Assert.Equal(0.9f, result.Volume); + Assert.NotNull(result.AdditionalProperties); + Assert.Equal("val", result.AdditionalProperties["key"]?.ToString()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseTests.cs new file mode 100644 index 00000000000..ccb80cc5f35 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseTests.cs @@ -0,0 +1,216 @@ +// 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.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextToSpeechResponseTests +{ + [Fact] + public void Constructor_InvalidArgs_Throws() + { + Assert.Throws("contents", () => new TextToSpeechResponse(null!)); + } + + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + TextToSpeechResponse response = new(); + Assert.Empty(response.Contents); + Assert.NotNull(response.Contents); + Assert.Same(response.Contents, response.Contents); + Assert.Empty(response.Contents); + Assert.Null(response.RawRepresentation); + Assert.Null(response.AdditionalProperties); + Assert.Null(response.Usage); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Constructor_List_PropsRoundtrip(int contentCount) + { + List content = []; + for (int i = 0; i < contentCount; i++) + { + content.Add(new DataContent(new byte[] { (byte)i }, "audio/mpeg")); + } + + TextToSpeechResponse response = new(content); + + Assert.Same(response.Contents, response.Contents); + if (contentCount == 0) + { + Assert.Empty(response.Contents); + } + else + { + Assert.Equal(contentCount, response.Contents.Count); + for (int i = 0; i < contentCount; i++) + { + DataContent dc = Assert.IsType(response.Contents[i]); + Assert.Equal("audio/mpeg", dc.MediaType); + } + } + } + + [Fact] + public void Properties_Roundtrip() + { + TextToSpeechResponse response = new(); + Assert.Null(response.ResponseId); + response.ResponseId = "id"; + Assert.Equal("id", response.ResponseId); + + Assert.Null(response.ModelId); + response.ModelId = "modelId"; + Assert.Equal("modelId", response.ModelId); + + Assert.Null(response.RawRepresentation); + object raw = new(); + response.RawRepresentation = raw; + Assert.Same(raw, response.RawRepresentation); + + Assert.Null(response.AdditionalProperties); + AdditionalPropertiesDictionary additionalProps = []; + response.AdditionalProperties = additionalProps; + Assert.Same(additionalProps, response.AdditionalProperties); + + List newContents = [new DataContent(new byte[] { 1 }, "audio/mpeg"), new DataContent(new byte[] { 2 }, "audio/mpeg")]; + response.Contents = newContents; + Assert.Same(newContents, response.Contents); + + Assert.Null(response.Usage); + UsageDetails usageDetails = new(); + response.Usage = usageDetails; + Assert.Same(usageDetails, response.Usage); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + TextToSpeechResponse original = new() + { + Contents = + [ + new DataContent(new byte[] { 1, 2, 3 }, "audio/mpeg"), + ], + ResponseId = "id", + ModelId = "modelId", + RawRepresentation = new(), + AdditionalProperties = new() { ["key"] = "value" }, + Usage = new() { InputTokenCount = 42, OutputTokenCount = 84, TotalTokenCount = 126 }, + }; + + string json = JsonSerializer.Serialize(original, TestJsonSerializerContext.Default.TextToSpeechResponse); + + TextToSpeechResponse? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.TextToSpeechResponse); + + Assert.NotNull(result); + Assert.Single(result.Contents); + + Assert.Equal("id", result.ResponseId); + Assert.Equal("modelId", result.ModelId); + + Assert.NotNull(result.AdditionalProperties); + Assert.Single(result.AdditionalProperties); + Assert.True(result.AdditionalProperties.TryGetValue("key", out object? value)); + Assert.IsType(value); + Assert.Equal("value", ((JsonElement)value!).GetString()); + + Assert.NotNull(result.Usage); + Assert.Equal(42, result.Usage.InputTokenCount); + Assert.Equal(84, result.Usage.OutputTokenCount); + Assert.Equal(126, result.Usage.TotalTokenCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ToTextToSpeechResponseUpdates_ReturnsExpectedUpdate(bool withUsage) + { + // Arrange: create a response with contents + TextToSpeechResponse response = new() + { + Contents = + [ + new DataContent(new byte[] { 1, 2, 3 }, "audio/mpeg"), + new DataContent(new byte[] { 4, 5, 6 }, "audio/wav"), + ], + ResponseId = "12345", + ModelId = "someModel", + AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 }, + Usage = withUsage ? new UsageDetails { InputTokenCount = 100, OutputTokenCount = 200, TotalTokenCount = 300 } : null + }; + + // Act: convert to streaming updates + TextToSpeechResponseUpdate[] updates = response.ToTextToSpeechResponseUpdates(); + + // Assert: should be a single update with all properties + Assert.Single(updates); + + TextToSpeechResponseUpdate update = updates[0]; + Assert.Equal("12345", update.ResponseId); + Assert.Equal("someModel", update.ModelId); + Assert.Equal(TextToSpeechResponseUpdateKind.AudioUpdated, update.Kind); + + Assert.Equal(withUsage ? 3 : 2, update.Contents.Count); + Assert.Equal("audio/mpeg", Assert.IsType(update.Contents[0]).MediaType); + Assert.Equal("audio/wav", Assert.IsType(update.Contents[1]).MediaType); + + Assert.NotNull(update.AdditionalProperties); + Assert.Equal("value1", update.AdditionalProperties["key1"]); + Assert.Equal(42, update.AdditionalProperties["key2"]); + + if (withUsage) + { + var usage = Assert.IsType(update.Contents[2]); + Assert.Equal(100, usage.Details.InputTokenCount); + Assert.Equal(200, usage.Details.OutputTokenCount); + Assert.Equal(300, usage.Details.TotalTokenCount); + } + } + + [Fact] + public void JsonDeserialization_KnownPayload() + { + const string Json = """ + { + "responseId": "resp1", + "modelId": "tts-1", + "contents": [ + { + "$type": "data", + "uri": "data:audio/mpeg;base64,AQID" + } + ], + "usage": { + "inputTokenCount": 100, + "outputTokenCount": 50 + }, + "additionalProperties": { + "key": "val" + } + } + """; + + TextToSpeechResponse? result = JsonSerializer.Deserialize(Json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(result); + Assert.Equal("resp1", result.ResponseId); + Assert.Equal("tts-1", result.ModelId); + Assert.Single(result.Contents); + var dataContent = Assert.IsType(result.Contents[0]); + Assert.Equal("audio/mpeg", dataContent.MediaType); + Assert.NotNull(result.Usage); + Assert.Equal(100, result.Usage.InputTokenCount); + Assert.Equal(50, result.Usage.OutputTokenCount); + Assert.NotNull(result.AdditionalProperties); + Assert.Equal("val", result.AdditionalProperties["key"]?.ToString()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateExtensionsTests.cs new file mode 100644 index 00000000000..b910bb63ea8 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateExtensionsTests.cs @@ -0,0 +1,83 @@ +// 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 Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextToSpeechResponseUpdateExtensionsTests +{ + [Fact] + public void InvalidArgs_Throws() + { + Assert.Throws("updates", () => ((List)null!).ToTextToSpeechResponse()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToTextToSpeechResponse_SuccessfullyCreatesResponse(bool useAsync) + { + TextToSpeechResponseUpdate[] updates = + [ + new([new DataContent(new byte[] { 1 }, "audio/mpeg")]) { ModelId = "model123", AdditionalProperties = new() { ["a"] = "b" } }, + new([new DataContent(new byte[] { 2 }, "audio/mpeg")]) { ModelId = "model123" }, + new([new DataContent(new byte[] { 3 }, "audio/mpeg")]) { ModelId = "model123", AdditionalProperties = new() { ["c"] = "d" } }, + new() { ResponseId = "someResponse", ModelId = "model123" }, + ]; + + TextToSpeechResponse response = useAsync ? + await YieldAsync(updates).ToTextToSpeechResponseAsync() : + updates.ToTextToSpeechResponse(); + + Assert.NotNull(response); + + Assert.Equal("someResponse", response.ResponseId); + Assert.Equal("model123", response.ModelId); + + Assert.NotNull(response.AdditionalProperties); + Assert.Equal(2, response.AdditionalProperties.Count); + Assert.Equal("b", response.AdditionalProperties["a"]); + Assert.Equal("d", response.AdditionalProperties["c"]); + + Assert.Equal(3, response.Contents.Count); + Assert.All(response.Contents, c => Assert.IsType(c)); + + Assert.Null(response.Usage); + } + + [Fact] + public async Task ToTextToSpeechResponse_UsageContentExtractedFromContents() + { + TextToSpeechResponseUpdate[] updates = + { + new() { Contents = [new DataContent(new byte[] { 1 }, "audio/mpeg")] }, + new() { Contents = [new UsageContent(new() { TotalTokenCount = 42 })] }, + new() { Contents = [new DataContent(new byte[] { 2 }, "audio/mpeg")] }, + new() { Contents = [new UsageContent(new() { InputTokenCount = 12, TotalTokenCount = 24 })] }, + }; + + TextToSpeechResponse response = await YieldAsync(updates).ToTextToSpeechResponseAsync(); + + Assert.NotNull(response); + + Assert.NotNull(response.Usage); + Assert.Equal(12, response.Usage.InputTokenCount); + Assert.Equal(66, response.Usage.TotalTokenCount); + + Assert.Equal(2, response.Contents.Count); + Assert.All(response.Contents, c => Assert.IsType(c)); + } + + private static async IAsyncEnumerable YieldAsync(IEnumerable updates) + { + foreach (TextToSpeechResponseUpdate update in updates) + { + await Task.Yield(); + yield return update; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateKindTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateKindTests.cs new file mode 100644 index 00000000000..4a43758179f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateKindTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextToSpeechResponseUpdateKindTests +{ + [Fact] + public void Constructor_Value_Roundtrips() + { + Assert.Equal("abc", new TextToSpeechResponseUpdateKind("abc").Value); + } + + [Fact] + public void Constructor_NullOrWhiteSpace_Throws() + { + Assert.Throws("value", () => new TextToSpeechResponseUpdateKind(null!)); + Assert.Throws("value", () => new TextToSpeechResponseUpdateKind(" ")); + } + + [Fact] + public void Equality_UsesOrdinalIgnoreCaseComparison() + { + var kind1 = new TextToSpeechResponseUpdateKind("abc"); + var kind2 = new TextToSpeechResponseUpdateKind("ABC"); + Assert.True(kind1.Equals(kind2)); + Assert.True(kind1.Equals((object)kind2)); + Assert.True(kind1 == kind2); + Assert.False(kind1 != kind2); + + var kind3 = new TextToSpeechResponseUpdateKind("def"); + Assert.False(kind1.Equals(kind3)); + Assert.False(kind1.Equals((object)kind3)); + Assert.False(kind1 == kind3); + Assert.True(kind1 != kind3); + + Assert.Equal(kind1.GetHashCode(), new TextToSpeechResponseUpdateKind("abc").GetHashCode()); + Assert.Equal(kind1.GetHashCode(), new TextToSpeechResponseUpdateKind("ABC").GetHashCode()); + } + + [Fact] + public void Singletons_UseKnownValues() + { + Assert.Equal(TextToSpeechResponseUpdateKind.SessionOpen.ToString(), TextToSpeechResponseUpdateKind.SessionOpen.Value); + Assert.Equal(TextToSpeechResponseUpdateKind.Error.ToString(), TextToSpeechResponseUpdateKind.Error.Value); + Assert.Equal(TextToSpeechResponseUpdateKind.AudioUpdating.ToString(), TextToSpeechResponseUpdateKind.AudioUpdating.Value); + Assert.Equal(TextToSpeechResponseUpdateKind.AudioUpdated.ToString(), TextToSpeechResponseUpdateKind.AudioUpdated.Value); + Assert.Equal(TextToSpeechResponseUpdateKind.SessionClose.ToString(), TextToSpeechResponseUpdateKind.SessionClose.Value); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + var kind = new TextToSpeechResponseUpdateKind("abc"); + string json = JsonSerializer.Serialize(kind, TestJsonSerializerContext.Default.TextToSpeechResponseUpdateKind); + Assert.Equal("\"abc\"", json); + + var result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.TextToSpeechResponseUpdateKind); + Assert.Equal(kind, result); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateTests.cs new file mode 100644 index 00000000000..49ebe0b1747 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TextToSpeech/TextToSpeechResponseUpdateTests.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextToSpeechResponseUpdateTests +{ + [Fact] + public void Constructor_PropsDefaulted() + { + TextToSpeechResponseUpdate update = new(); + + Assert.Equal(TextToSpeechResponseUpdateKind.AudioUpdating, update.Kind); + Assert.Empty(update.Contents); + Assert.Null(update.ResponseId); + } + + [Fact] + public void Properties_Roundtrip() + { + TextToSpeechResponseUpdate update = new() + { + Kind = new TextToSpeechResponseUpdateKind("custom"), + }; + + Assert.Equal("custom", update.Kind.Value); + + // Contents: assigning a new list then resetting to null should yield an empty list. + List newList = [new DataContent(new byte[] { 1 }, "audio/mpeg")]; + update.Contents = newList; + Assert.Same(newList, update.Contents); + update.Contents = null; + Assert.NotNull(update.Contents); + Assert.Empty(update.Contents); + + update.ResponseId = "comp123"; + Assert.Equal("comp123", update.ResponseId); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + TextToSpeechResponseUpdate original = new() + { + Kind = new TextToSpeechResponseUpdateKind("audiogenerated"), + ResponseId = "id123", + Contents = + [ + new DataContent(new byte[] { 1, 2, 3 }, "audio/mpeg"), + ] + }; + + string json = JsonSerializer.Serialize(original, TestJsonSerializerContext.Default.TextToSpeechResponseUpdate); + TextToSpeechResponseUpdate? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.TextToSpeechResponseUpdate); + Assert.NotNull(result); + + Assert.Equal(original.Kind, result.Kind); + Assert.Equal(original.ResponseId, result.ResponseId); + Assert.Equal(original.Contents.Count, result.Contents.Count); + } + + [Fact] + public void JsonDeserialization_KnownPayload() + { + const string Json = """ + { + "kind": "audioupdated", + "responseId": "resp1", + "modelId": "tts-1", + "contents": [ + { + "$type": "data", + "uri": "data:audio/mpeg;base64,AQID" + } + ], + "additionalProperties": { + "key": "val" + } + } + """; + + TextToSpeechResponseUpdate? result = JsonSerializer.Deserialize(Json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(result); + Assert.Equal(TextToSpeechResponseUpdateKind.AudioUpdated, result.Kind); + Assert.Equal("resp1", result.ResponseId); + Assert.Equal("tts-1", result.ModelId); + Assert.Single(result.Contents); + var dataContent = Assert.IsType(result.Contents[0]); + Assert.Equal("audio/mpeg", dataContent.MediaType); + Assert.NotNull(result.AdditionalProperties); + Assert.Equal("val", result.AdditionalProperties["key"]?.ToString()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/UsageDetailsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/UsageDetailsTests.cs index e401a341b43..c4fdaccf312 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/UsageDetailsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/UsageDetailsTests.cs @@ -5,6 +5,8 @@ using System.Text.Json; using Xunit; +#pragma warning disable MEAI001 // Experimental API + namespace Microsoft.Extensions.AI; public class UsageDetailsTests @@ -18,6 +20,10 @@ public void Constructor_PropsDefault() Assert.Null(details.TotalTokenCount); Assert.Null(details.CachedInputTokenCount); Assert.Null(details.ReasoningTokenCount); + Assert.Null(details.InputAudioTokenCount); + Assert.Null(details.InputTextTokenCount); + Assert.Null(details.OutputAudioTokenCount); + Assert.Null(details.OutputTextTokenCount); Assert.Null(details.AdditionalCounts); } @@ -31,6 +37,10 @@ public void Properties_Roundtrip() TotalTokenCount = 30, CachedInputTokenCount = 5, ReasoningTokenCount = 8, + InputAudioTokenCount = 50, + InputTextTokenCount = 60, + OutputAudioTokenCount = 70, + OutputTextTokenCount = 80, AdditionalCounts = new() { ["custom"] = 100 } }; @@ -39,6 +49,10 @@ public void Properties_Roundtrip() Assert.Equal(30, details.TotalTokenCount); Assert.Equal(5, details.CachedInputTokenCount); Assert.Equal(8, details.ReasoningTokenCount); + Assert.Equal(50, details.InputAudioTokenCount); + Assert.Equal(60, details.InputTextTokenCount); + Assert.Equal(70, details.OutputAudioTokenCount); + Assert.Equal(80, details.OutputTextTokenCount); Assert.NotNull(details.AdditionalCounts); Assert.Equal(100, details.AdditionalCounts["custom"]); } @@ -60,6 +74,10 @@ public void Add_SumsAllProperties() TotalTokenCount = 30, CachedInputTokenCount = 5, ReasoningTokenCount = 8, + InputAudioTokenCount = 10, + InputTextTokenCount = 20, + OutputAudioTokenCount = 30, + OutputTextTokenCount = 40, }; UsageDetails details2 = new() @@ -69,6 +87,10 @@ public void Add_SumsAllProperties() TotalTokenCount = 40, CachedInputTokenCount = 7, ReasoningTokenCount = 12, + InputAudioTokenCount = 15, + InputTextTokenCount = 25, + OutputAudioTokenCount = 35, + OutputTextTokenCount = 45, }; details1.Add(details2); @@ -78,6 +100,10 @@ public void Add_SumsAllProperties() Assert.Equal(70, details1.TotalTokenCount); Assert.Equal(12, details1.CachedInputTokenCount); Assert.Equal(20, details1.ReasoningTokenCount); + Assert.Equal(25, details1.InputAudioTokenCount); + Assert.Equal(45, details1.InputTextTokenCount); + Assert.Equal(65, details1.OutputAudioTokenCount); + Assert.Equal(85, details1.OutputTextTokenCount); } [Fact] @@ -152,6 +178,10 @@ public void Serialization_Roundtrips() TotalTokenCount = 30, CachedInputTokenCount = 5, ReasoningTokenCount = 8, + InputAudioTokenCount = 50, + InputTextTokenCount = 60, + OutputAudioTokenCount = 70, + OutputTextTokenCount = 80, AdditionalCounts = new() { ["custom"] = 100 } }; @@ -164,6 +194,10 @@ public void Serialization_Roundtrips() Assert.Equal(details.TotalTokenCount, deserialized.TotalTokenCount); Assert.Equal(details.CachedInputTokenCount, deserialized.CachedInputTokenCount); Assert.Equal(details.ReasoningTokenCount, deserialized.ReasoningTokenCount); + Assert.Equal(details.InputAudioTokenCount, deserialized.InputAudioTokenCount); + Assert.Equal(details.InputTextTokenCount, deserialized.InputTextTokenCount); + Assert.Equal(details.OutputAudioTokenCount, deserialized.OutputAudioTokenCount); + Assert.Equal(details.OutputTextTokenCount, deserialized.OutputTextTokenCount); Assert.NotNull(deserialized.AdditionalCounts); Assert.Equal(100, deserialized.AdditionalCounts["custom"]); } @@ -186,6 +220,10 @@ public void Serialization_WithNullProperties_Roundtrips() Assert.Null(deserialized.TotalTokenCount); Assert.Null(deserialized.CachedInputTokenCount); Assert.Null(deserialized.ReasoningTokenCount); + Assert.Null(deserialized.InputAudioTokenCount); + Assert.Null(deserialized.InputTextTokenCount); + Assert.Null(deserialized.OutputAudioTokenCount); + Assert.Null(deserialized.OutputTextTokenCount); } [Fact] @@ -198,6 +236,10 @@ public void JsonDeserialization_KnownPayload() "totalTokenCount": 30, "cachedInputTokenCount": 5, "reasoningTokenCount": 8, + "inputAudioTokenCount": 50, + "inputTextTokenCount": 60, + "outputAudioTokenCount": 70, + "outputTextTokenCount": 80, "additionalCounts": { "custom": 100 } @@ -212,6 +254,10 @@ public void JsonDeserialization_KnownPayload() Assert.Equal(30, result.TotalTokenCount); Assert.Equal(5, result.CachedInputTokenCount); Assert.Equal(8, result.ReasoningTokenCount); + Assert.Equal(50, result.InputAudioTokenCount); + Assert.Equal(60, result.InputTextTokenCount); + Assert.Equal(70, result.OutputAudioTokenCount); + Assert.Equal(80, result.OutputTextTokenCount); Assert.NotNull(result.AdditionalCounts); Assert.Single(result.AdditionalCounts); Assert.Equal(100, result.AdditionalCounts["custom"]); diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/DiskBased/PathValidationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/DiskBased/PathValidationTests.cs new file mode 100644 index 00000000000..bcf7dceaa3d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/DiskBased/PathValidationTests.cs @@ -0,0 +1,486 @@ +// 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.InteropServices; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Reporting.Utilities; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Reporting.Tests; + +public class PathValidationTests +{ + // ────────────────────────────────────────────── + // ValidatePathSegment – valid inputs + // ────────────────────────────────────────────── + + [Fact] + public void ValidatePathSegment_Null_DoesNotThrow() + { + PathValidation.ValidatePathSegment(null, "param"); + } + + [Theory] + [InlineData("simple")] + [InlineData("My Scenario")] + [InlineData("run-2024-01-01")] + [InlineData("iteration_0")] + [InlineData("a")] + [InlineData("...")] + [InlineData("..x")] + [InlineData("x..")] + public void ValidatePathSegment_ValidNames_DoesNotThrow(string segment) + { + PathValidation.ValidatePathSegment(segment, "param"); + } + + // ────────────────────────────────────────────── + // ValidatePathSegment – invalid inputs + // ────────────────────────────────────────────── + + [Fact] + public void ValidatePathSegment_EmptyString_Throws() + { + Assert.Throws(() => + PathValidation.ValidatePathSegment("", "param")); + } + + [Theory] + [InlineData("..")] + [InlineData(".")] + public void ValidatePathSegment_TraversalLiterals_Throws(string segment) + { + Assert.Throws(() => + PathValidation.ValidatePathSegment(segment, "param")); + } + + [Theory] + [InlineData("foo/bar")] + [InlineData("../secret")] + public void ValidatePathSegment_ContainsForwardSlash_Throws(string segment) + { + Assert.Throws(() => + PathValidation.ValidatePathSegment(segment, "param")); + } + + [Theory] + [InlineData("foo\\bar")] + [InlineData("..\\secret")] + public void ValidatePathSegment_ContainsBackslash_ThrowsOnWindows(string segment) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Backslash is a path separator (and invalid filename char) on Windows. + Assert.Throws(() => + PathValidation.ValidatePathSegment(segment, "param")); + } + else + { + // Backslash is a valid filename character on Linux/macOS. + PathValidation.ValidatePathSegment(segment, "param"); + } + } + + [Theory] + [InlineData(" leading")] + [InlineData("trailing ")] + [InlineData(" both ")] + public void ValidatePathSegment_WhitespacePadded_Throws(string segment) + { + Assert.Throws(() => + PathValidation.ValidatePathSegment(segment, "param")); + } + + [Fact] + public void ValidatePathSegment_NullCharacter_Throws() + { + Assert.Throws(() => + PathValidation.ValidatePathSegment("foo\0bar", "param")); + } + + // ────────────────────────────────────────────── + // EnsureWithinRoot – paths inside root + // ────────────────────────────────────────────── + + [Fact] + public void EnsureWithinRoot_ChildPath_ReturnsResolvedPath() + { + string root = Path.Combine(Path.GetTempPath(), "testroot"); + string child = Path.Combine(root, "sub", "file.txt"); + + string result = PathValidation.EnsureWithinRoot(root, child); + + Assert.Equal(Path.GetFullPath(child), result); + } + + [Fact] + public void EnsureWithinRoot_DeeplyNested_ReturnsResolvedPath() + { + string root = Path.Combine(Path.GetTempPath(), "testroot"); + string child = Path.Combine(root, "a", "b", "c", "d.json"); + + string result = PathValidation.EnsureWithinRoot(root, child); + + Assert.Equal(Path.GetFullPath(child), result); + } + + [Fact] + public void EnsureWithinRoot_RootWithTrailingSeparator_Works() + { + string root = Path.Combine(Path.GetTempPath(), "testroot") + Path.DirectorySeparatorChar; + string child = Path.Combine(root, "file.txt"); + + string result = PathValidation.EnsureWithinRoot(root, child); + + Assert.Equal(Path.GetFullPath(child), result); + } + + // ────────────────────────────────────────────── + // EnsureWithinRoot – paths escaping root + // ────────────────────────────────────────────── + + [Fact] + public void EnsureWithinRoot_DotDotEscapes_Throws() + { + string root = Path.Combine(Path.GetTempPath(), "testroot"); + string escaped = Path.Combine(root, "..", "outside"); + + Assert.Throws(() => + PathValidation.EnsureWithinRoot(root, escaped)); + } + + [Fact] + public void EnsureWithinRoot_MultipleDotDots_Throws() + { + string root = Path.Combine(Path.GetTempPath(), "testroot", "nested"); + string escaped = Path.Combine(root, "..", "..", "outside"); + + Assert.Throws(() => + PathValidation.EnsureWithinRoot(root, escaped)); + } + + [Fact] + public void EnsureWithinRoot_CompletelyDifferentPath_Throws() + { + string root = Path.Combine(Path.GetTempPath(), "testroot"); + string other = Path.Combine(Path.GetTempPath(), "other", "file.txt"); + + Assert.Throws(() => + PathValidation.EnsureWithinRoot(root, other)); + } + + [Fact] + public void EnsureWithinRoot_SiblingWithPrefix_Throws() + { + // Verifies that "testroot-sibling" is NOT treated as being inside "testroot". + string root = Path.Combine(Path.GetTempPath(), "testroot"); + string sibling = Path.Combine(Path.GetTempPath(), "testroot-sibling", "file.txt"); + + Assert.Throws(() => + PathValidation.EnsureWithinRoot(root, sibling)); + } + + [Fact] + public void EnsureWithinRoot_PathEqualsRoot_DoesNotThrow() + { + string root = Path.Combine(Path.GetTempPath(), "testroot"); + + string result = PathValidation.EnsureWithinRoot(root, root); + + Assert.Equal(Path.GetFullPath(root), result); + } + + // ────────────────────────────────────────────── + // Integration: DiskBasedResultStore rejects traversal + // ────────────────────────────────────────────── + + [Fact] + public async Task DiskBasedResultStore_DeleteWithTraversal_Throws() + { + string storagePath = Path.Combine(Path.GetTempPath(), "M.E.AI.Eval.PathTests", Path.GetRandomFileName()); + + try + { + Directory.CreateDirectory(storagePath); + var store = new Storage.DiskBasedResultStore(storagePath); + + await Assert.ThrowsAsync(() => + store.DeleteResultsAsync(executionName: "..", scenarioName: "../sentinel").AsTask()); + } + finally + { + try + { + Directory.Delete(storagePath, true); + } +#pragma warning disable CA1031 // Do not catch general exception types. + catch +#pragma warning restore CA1031 + { + // Best effort cleanup. + } + } + } + + [Fact] + public async Task DiskBasedResponseCacheProvider_TraversalInScenarioName_Throws() + { + string storagePath = Path.Combine(Path.GetTempPath(), "M.E.AI.Eval.PathTests", Path.GetRandomFileName()); + + try + { + Directory.CreateDirectory(storagePath); + var provider = new Storage.DiskBasedResponseCacheProvider(storagePath); + + await Assert.ThrowsAsync(() => + provider.GetCacheAsync("..", "..").AsTask()); + } + finally + { + try + { + Directory.Delete(storagePath, true); + } +#pragma warning disable CA1031 // Do not catch general exception types. + catch +#pragma warning restore CA1031 + { + // Best effort cleanup. + } + } + } + + // ────────────────────────────────────────────── + // EnsureWithinRoot – UNC paths (Windows only) + // ────────────────────────────────────────────── + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void EnsureWithinRoot_UncPath_ChildPath_ReturnsResolved() + { + string root = @"\\server\share\data"; + string child = @"\\server\share\data\sub\file.txt"; + + string result = PathValidation.EnsureWithinRoot(root, child); + + Assert.Equal(Path.GetFullPath(child), result); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void EnsureWithinRoot_UncPath_DifferentShare_Throws() + { + string root = @"\\server\share\data"; + string other = @"\\server\share\other\file.txt"; + + Assert.Throws(() => + PathValidation.EnsureWithinRoot(root, other)); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void EnsureWithinRoot_UncPath_DotDotEscapes_Throws() + { + string root = @"\\server\share\data"; + string escaped = @"\\server\share\data\..\other"; + + Assert.Throws(() => + PathValidation.EnsureWithinRoot(root, escaped)); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void EnsureWithinRoot_UncPath_SiblingWithPrefix_Throws() + { + string root = @"\\server\share\data"; + string sibling = @"\\server\share\data-sibling\file.txt"; + + Assert.Throws(() => + PathValidation.EnsureWithinRoot(root, sibling)); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void EnsureWithinRoot_UncPath_PathEqualsRoot_DoesNotThrow() + { + string root = @"\\server\share\data"; + + string result = PathValidation.EnsureWithinRoot(root, root); + + Assert.Equal(Path.GetFullPath(root), result); + } + + // ────────────────────────────────────────────── + // EnsureWithinRoot – short (8.3) Windows paths + // ────────────────────────────────────────────── + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void EnsureWithinRoot_ShortPathRoot_LongPathChild_DocumentedBehavior() + { + // Short (8.3) paths are NOT consistently normalized by Path.GetFullPath + // across .NET versions. This test documents that if the root uses a short + // path form and the child uses the long form, the behavior depends on + // whether GetFullPath expands 8.3 names on the current runtime. + // This is acceptable because callers always construct paths relative + // to the same root string via Path.Combine. + string longRoot = Path.Combine(Path.GetTempPath(), "LongDirectoryName_ForTesting"); + Directory.CreateDirectory(longRoot); + try + { + string shortRoot = GetShortPath(longRoot); + if (string.Equals(shortRoot, longRoot, StringComparison.OrdinalIgnoreCase)) + { + // 8.3 names not enabled on this volume — skip silently. + return; + } + + string longChild = Path.Combine(longRoot, "file.txt"); + + // Check whether this runtime expands 8.3 names in GetFullPath. + bool runtimeExpands8Dot3 = string.Equals( + Path.GetFullPath(shortRoot), + Path.GetFullPath(longRoot), + StringComparison.OrdinalIgnoreCase); + + if (runtimeExpands8Dot3) + { + // GetFullPath normalizes both to long form — mixed usage works. + string result = PathValidation.EnsureWithinRoot(shortRoot, longChild); + Assert.Equal(Path.GetFullPath(longChild), result); + } + else + { + // GetFullPath preserves 8.3 — mixed representations don't match. + Assert.Throws(() => + PathValidation.EnsureWithinRoot(shortRoot, longChild)); + } + } + finally + { + Directory.Delete(longRoot, true); + } + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void EnsureWithinRoot_ConsistentShortPaths_Works() + { + // When both root and child are constructed from the same short-path + // string via Path.Combine, EnsureWithinRoot succeeds because + // Path.GetFullPath treats both consistently. + // Note: the child file must exist on disk because some runtimes + // (e.g. .NET Framework) expand 8.3 names only for existing paths. + string longRoot = Path.Combine(Path.GetTempPath(), "LongDirectoryName_ForTesting"); + Directory.CreateDirectory(longRoot); + try + { + string shortRoot = GetShortPath(longRoot); + if (string.Equals(shortRoot, longRoot, StringComparison.OrdinalIgnoreCase)) + { + // 8.3 names not enabled on this volume — skip silently. + return; + } + + // Create the child file so GetFullPath expands consistently. + string child = Path.Combine(shortRoot, "file.txt"); + File.WriteAllText(Path.Combine(longRoot, "file.txt"), string.Empty); + + string result = PathValidation.EnsureWithinRoot(shortRoot, child); + + Assert.Equal(Path.GetFullPath(child), result); + } + finally + { + Directory.Delete(longRoot, true); + } + } + + // ────────────────────────────────────────────── + // EnsureWithinRoot – additional edge cases + // ────────────────────────────────────────────── + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void EnsureWithinRoot_AltSeparatorInRoot_Works() + { + // Forward slash is an alternate directory separator on Windows. + string root = Path.GetTempPath().Replace('\\', '/') + "testroot"; + string child = Path.Combine(root, "sub", "file.txt"); + + string result = PathValidation.EnsureWithinRoot(root, child); + + Assert.Equal(Path.GetFullPath(child), result); + } + + [Fact] + public void EnsureWithinRoot_CaseMismatch_BehavesPerPlatform() + { + string root = Path.Combine(Path.GetTempPath(), "TestRoot"); + string child = Path.Combine(Path.GetTempPath(), "testroot", "file.txt"); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Windows is case-insensitive: should succeed. + string result = PathValidation.EnsureWithinRoot(root, child); + Assert.Equal(Path.GetFullPath(child), result); + } + else + { + // Linux/macOS is case-sensitive: should throw. + Assert.Throws(() => + PathValidation.EnsureWithinRoot(root, child)); + } + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void EnsureWithinRoot_DriveRoot_ChildPath_Works() + { + string root = @"C:\"; + string child = @"C:\some\nested\file.txt"; + + string result = PathValidation.EnsureWithinRoot(root, child); + + Assert.Equal(Path.GetFullPath(child), result); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void EnsureWithinRoot_DriveRoot_DifferentDrive_Throws() + { + string root = @"C:\data"; + string other = @"D:\data\file.txt"; + + Assert.Throws(() => + PathValidation.EnsureWithinRoot(root, other)); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Windows)] + public void EnsureWithinRoot_UnixAbsoluteRoot_ChildPath_Works() + { + string root = "/tmp/testroot"; + string child = "/tmp/testroot/sub/file.txt"; + + string result = PathValidation.EnsureWithinRoot(root, child); + + Assert.Equal(Path.GetFullPath(child), result); + } + + private static class NativeMethods + { + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + internal static extern uint GetShortPathNameW(string lpszLongPath, char[] lpszShortPath, uint cchBuffer); + } + + private static string GetShortPath(string longPath) + { + var buffer = new char[260]; + uint len = NativeMethods.GetShortPathNameW(longPath, buffer, (uint)buffer.Length); + return len > 0 ? new string(buffer, 0, (int)len) : longPath; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/TextToSpeechClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/TextToSpeechClientIntegrationTests.cs new file mode 100644 index 00000000000..823a225052a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/TextToSpeechClientIntegrationTests.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +#pragma warning disable CA2214 // Do not call overridable methods in constructors + +namespace Microsoft.Extensions.AI; + +public abstract class TextToSpeechClientIntegrationTests : IDisposable +{ + private readonly ITextToSpeechClient? _client; + + protected TextToSpeechClientIntegrationTests() + { + _client = CreateClient(); + } + + public void Dispose() + { + _client?.Dispose(); + GC.SuppressFinalize(this); + } + + protected abstract ITextToSpeechClient? CreateClient(); + + [ConditionalFact] + public virtual async Task GetAudioAsync_SimpleText_ReturnsAudio() + { + SkipIfNotEnabled(); + + var response = await _client.GetAudioAsync("Hello, world!"); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + + var content = Assert.Single(response.Contents); + var dataContent = Assert.IsType(content); + Assert.False(dataContent.Data.IsEmpty); + Assert.StartsWith("audio/", dataContent.MediaType, StringComparison.Ordinal); + } + + [ConditionalFact] + public virtual async Task GetAudioAsync_WithVoice_ReturnsAudio() + { + SkipIfNotEnabled(); + + var response = await _client.GetAudioAsync("The quick brown fox jumps over the lazy dog.", new TextToSpeechOptions + { + VoiceId = "nova", + }); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + + var content = Assert.Single(response.Contents); + var dataContent = Assert.IsType(content); + Assert.False(dataContent.Data.IsEmpty); + Assert.StartsWith("audio/", dataContent.MediaType, StringComparison.Ordinal); + } + + [ConditionalFact] + public virtual async Task GetAudioAsync_WithAudioFormat_ReturnsCorrectMediaType() + { + SkipIfNotEnabled(); + + var response = await _client.GetAudioAsync("Testing audio format selection.", new TextToSpeechOptions + { + AudioFormat = "opus", + }); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + + var content = Assert.Single(response.Contents); + var dataContent = Assert.IsType(content); + Assert.False(dataContent.Data.IsEmpty); + Assert.Equal("audio/opus", dataContent.MediaType); + } + + [ConditionalFact] + public virtual async Task GetAudioAsync_WithSpeed_ReturnsAudio() + { + SkipIfNotEnabled(); + + var response = await _client.GetAudioAsync("This should be spoken quickly.", new TextToSpeechOptions + { + Speed = 1.5f, + }); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + + var content = Assert.Single(response.Contents); + var dataContent = Assert.IsType(content); + Assert.False(dataContent.Data.IsEmpty); + } + + [ConditionalFact] + public virtual async Task GetStreamingAudioAsync_SimpleText_ReturnsUpdates() + { + SkipIfNotEnabled(); + + int updateCount = 0; + await foreach (var update in _client.GetStreamingAudioAsync("Hello, world!")) + { + updateCount++; + Assert.NotNull(update); + + var dataContents = update.Contents.OfType().ToList(); + Assert.NotEmpty(dataContents); + + foreach (var dataContent in dataContents) + { + Assert.False(dataContent.Data.IsEmpty); + Assert.StartsWith("audio/", dataContent.MediaType, StringComparison.Ordinal); + } + } + + Assert.True(updateCount > 0, "Expected at least one streaming update."); + } + + [MemberNotNull(nameof(_client))] + protected void SkipIfNotEnabled() + { + string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"]; + + if (skipIntegration is not null || _client is null) + { + throw new SkipTestException("Client is not enabled."); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeClientSessionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeClientSessionTests.cs new file mode 100644 index 00000000000..71faf895d93 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeClientSessionTests.cs @@ -0,0 +1,96 @@ +// 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.Threading; +using System.Threading.Tasks; +using Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + +namespace Microsoft.Extensions.AI; + +public class OpenAIRealtimeClientSessionTests +{ + [Fact] + public async Task GetService_ReturnsExpectedServices() + { + await using IRealtimeClientSession session = new OpenAIRealtimeClientSession("key", "model"); + + Assert.Same(session, session.GetService(typeof(OpenAIRealtimeClientSession))); + Assert.Same(session, session.GetService(typeof(IRealtimeClientSession))); + Assert.Null(session.GetService(typeof(string))); + Assert.Null(session.GetService(typeof(OpenAIRealtimeClientSession), "someKey")); + } + + [Fact] + public async Task GetService_NullServiceType_Throws() + { + await using IRealtimeClientSession session = new OpenAIRealtimeClientSession("key", "model"); + Assert.Throws("serviceType", () => session.GetService(null!)); + } + + [Fact] + public async Task DisposeAsync_CanBeCalledMultipleTimes() + { + IRealtimeClientSession session = new OpenAIRealtimeClientSession("key", "model"); + await session.DisposeAsync(); + + // Second dispose should not throw. + await session.DisposeAsync(); + Assert.Null(session.GetService(typeof(string))); + } + + [Fact] + public async Task Options_InitiallyNull() + { + await using var session = new OpenAIRealtimeClientSession("key", "model"); + Assert.Null(session.Options); + } + + [Fact] + public void SessionUpdateMessage_NullOptions_Throws() + { + Assert.Throws("options", () => new SessionUpdateRealtimeClientMessage(null!)); + } + + [Fact] + public async Task SendAsync_NullMessage_Throws() + { + await using var session = new OpenAIRealtimeClientSession("key", "model"); + await Assert.ThrowsAsync("message", () => session.SendAsync(null!)); + } + + [Fact] + public async Task SendAsync_CancelledToken_ThrowsOperationCanceledException() + { + await using var session = new OpenAIRealtimeClientSession("key", "model"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Should throw when cancellation is requested. + await Assert.ThrowsAsync(() => session.SendAsync(new RealtimeClientMessage(), cts.Token)); + } + + [Fact] + public async Task SendAsync_NotConnected_ThrowsInvalidOperationException() + { + await using var session = new OpenAIRealtimeClientSession("key", "model"); + + // Should throw when session is not connected. + await Assert.ThrowsAsync(() => session.SendAsync(new RealtimeClientMessage())); + } + + [Fact] + public async Task ConnectAsync_CancelledToken_Throws() + { + await using var session = new OpenAIRealtimeClientSession("key", "model"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var ex = await Assert.ThrowsAnyAsync(() => session.ConnectAsync(cts.Token)); + Assert.True( + ex is OperationCanceledException || ex is System.Net.WebSockets.WebSocketException, + $"Expected OperationCanceledException or WebSocketException but got {ex.GetType().FullName}"); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeClientTests.cs new file mode 100644 index 00000000000..3167f7da7e0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeClientTests.cs @@ -0,0 +1,63 @@ +// 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.Threading; +using System.Threading.Tasks; +using Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + +namespace Microsoft.Extensions.AI; + +public class OpenAIRealtimeClientTests +{ + [Fact] + public void Ctor_InvalidArgs_Throws() + { + Assert.Throws("apiKey", () => new OpenAIRealtimeClient((string)null!, "model")); + Assert.Throws("model", () => new OpenAIRealtimeClient("key", null!)); + } + + [Fact] + public void GetService_ReturnsExpectedServices() + { + using IRealtimeClient client = new OpenAIRealtimeClient("key", "model"); + + Assert.Same(client, client.GetService(typeof(OpenAIRealtimeClient))); + Assert.Same(client, client.GetService(typeof(IRealtimeClient))); + Assert.Null(client.GetService(typeof(string))); + Assert.Null(client.GetService(typeof(OpenAIRealtimeClient), "someKey")); + } + + [Fact] + public void GetService_NullServiceType_Throws() + { + using IRealtimeClient client = new OpenAIRealtimeClient("key", "model"); + Assert.Throws("serviceType", () => client.GetService(null!)); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + IRealtimeClient client = new OpenAIRealtimeClient("key", "model"); + client.Dispose(); + + // Second dispose should not throw. + client.Dispose(); + Assert.Null(client.GetService(typeof(string))); + } + + [Fact] + public async Task CreateSessionAsync_Cancelled_Throws() + { + using var client = new OpenAIRealtimeClient("key", "model"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var ex = await Assert.ThrowsAnyAsync(() => client.CreateSessionAsync(cancellationToken: cts.Token)); + Assert.True( + ex is OperationCanceledException || ex is System.Net.WebSockets.WebSocketException, + $"Expected OperationCanceledException or WebSocketException but got {ex.GetType().FullName}"); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index ee98551e575..d5fe1c7508f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -4200,6 +4200,143 @@ public async Task ConversationId_WhenStoreDisabled_ReturnsNull_Streaming() } } + [Fact] + public async Task ConversationId_ReturnsNullWhenResponseStoreFieldFalse_NonStreaming() + { + // Simulates a provider that returns "store":false in the response + // even though the request didn't explicitly set StoredOutputEnabled = false. + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "store": false, + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Null(response.ConversationId); + } + + [Fact] + public async Task ConversationId_ReturnsNullWhenResponseStoreFieldFalse_Streaming() + { + // Simulates a provider (e.g. OpenRouter) that returns "store":false in streamed response events + // even though the request didn't explicitly set StoredOutputEnabled = false. + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":false,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":false,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":false,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); + Assert.Null(updates[i].ConversationId); + } + } + [Fact] public async Task ConversationId_AsConversationId_Streaming() { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs index 2ba70995a86..1e7aa1deb13 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs @@ -270,7 +270,6 @@ public async Task GetTextAsync_Translation_StronglyTypedOptions_AllSent() using var audioSpeechStream = GetAudioStream(); Assert.NotNull(await client.GetTextAsync(audioSpeechStream, new() { - SpeechLanguage = null, TextLanguage = "pt", RawRepresentationFactory = (s) => new AudioTranslationOptions diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAITextToSpeechClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAITextToSpeechClientIntegrationTests.cs new file mode 100644 index 00000000000..6f55c873bb1 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAITextToSpeechClientIntegrationTests.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public class OpenAITextToSpeechClientIntegrationTests : TextToSpeechClientIntegrationTests +{ + protected override ITextToSpeechClient? CreateClient() + => IntegrationTestHelpers.GetOpenAIClient()? + .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:TextToSpeechModel"] ?? "tts-1") + .AsITextToSpeechClient(); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAITextToSpeechClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAITextToSpeechClientTests.cs new file mode 100644 index 00000000000..3a16ef9f5c7 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAITextToSpeechClientTests.cs @@ -0,0 +1,283 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OpenAI; +using OpenAI.Audio; +using Xunit; + +#pragma warning disable S103 // Lines should not be too long +#pragma warning disable OPENAI001 // Experimental OpenAI APIs + +namespace Microsoft.Extensions.AI; + +public class OpenAITextToSpeechClientTests +{ + [Fact] + public void AsITextToSpeechClient_InvalidArgs_Throws() + { + Assert.Throws("audioClient", () => ((AudioClient)null!).AsITextToSpeechClient()); + } + + [Fact] + public void AsITextToSpeechClient_AudioClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "tts-1"; + + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + + ITextToSpeechClient ttsClient = client.GetAudioClient(model).AsITextToSpeechClient(); + var metadata = ttsClient.GetService(); + Assert.Equal("openai", metadata?.ProviderName); + Assert.Equal(endpoint, metadata?.ProviderUri); + Assert.Equal(model, metadata?.DefaultModelId); + } + + [Fact] + public void GetService_AudioClient_SuccessfullyReturnsUnderlyingClient() + { + AudioClient audioClient = new OpenAIClient(new ApiKeyCredential("key")).GetAudioClient("tts-1"); + ITextToSpeechClient ttsClient = audioClient.AsITextToSpeechClient(); + Assert.Same(ttsClient, ttsClient.GetService()); + Assert.Same(audioClient, ttsClient.GetService()); + using var factory = LoggerFactory.Create(b => b.AddFakeLogging()); + using ITextToSpeechClient pipeline = ttsClient + .AsBuilder() + .UseLogging(factory) + .Build(); + + Assert.NotNull(pipeline.GetService()); + + Assert.Same(audioClient, pipeline.GetService()); + Assert.IsType(pipeline.GetService()); + } + + [Fact] + public async Task GetAudioAsync_DefaultVoice_BasicRequestResponse() + { + const string Input = """ + { + "model": "tts-1", + "input": "Hello world", + "voice": "alloy" + } + """; + + const string Output = "fake-audio-bytes"; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1"); + + var response = await client.GetAudioAsync("Hello world"); + + Assert.NotNull(response); + Assert.Equal("tts-1", response.ModelId); + Assert.NotNull(response.RawRepresentation); + Assert.Single(response.Contents); + var content = Assert.IsType(response.Contents[0]); + Assert.Equal("audio/mpeg", content.MediaType); + Assert.True(content.Data.Length > 0); + } + + [Fact] + public async Task GetAudioAsync_CustomVoice_SetsVoice() + { + const string Input = """ + { + "model": "tts-1", + "input": "Hello world", + "voice": "nova" + } + """; + + const string Output = "fake-audio-bytes"; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1"); + + var response = await client.GetAudioAsync("Hello world", new TextToSpeechOptions + { + VoiceId = "nova" + }); + + Assert.NotNull(response); + Assert.Single(response.Contents); + var content = Assert.IsType(response.Contents[0]); + Assert.Equal("audio/mpeg", content.MediaType); + } + + [Fact] + public async Task GetAudioAsync_SpeedMapping_SetsSpeedRatio() + { + const string Input = """ + { + "model": "tts-1", + "input": "Hello world", + "voice": "alloy", + "speed": 1.5 + } + """; + + const string Output = "fake-audio-bytes"; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1"); + + var response = await client.GetAudioAsync("Hello world", new TextToSpeechOptions + { + Speed = 1.5f + }); + + Assert.NotNull(response); + Assert.Single(response.Contents); + } + + [Theory] + [InlineData("opus", "audio/opus")] + [InlineData("wav", "audio/wav")] + [InlineData("mp3", "audio/mpeg")] + [InlineData("aac", "audio/aac")] + [InlineData("flac", "audio/flac")] + [InlineData("pcm", "audio/l16")] + public async Task GetAudioAsync_AudioFormat_SetsFormatAndMediaType(string audioFormat, string expectedMediaType) + { + string input = $$""" + { + "model": "tts-1", + "input": "Hello world", + "voice": "alloy", + "response_format": "{{audioFormat}}" + } + """; + + const string Output = "fake-audio-bytes"; + + using VerbatimHttpHandler handler = new(input, Output); + using HttpClient httpClient = new(handler); + using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1"); + + var response = await client.GetAudioAsync("Hello world", new TextToSpeechOptions + { + AudioFormat = audioFormat + }); + + Assert.NotNull(response); + Assert.Single(response.Contents); + var content = Assert.IsType(response.Contents[0]); + Assert.Equal(expectedMediaType, content.MediaType); + } + + [Fact] + public async Task GetAudioAsync_StronglyTypedOptions_AllSent() + { + const string Input = """ + { + "model": "tts-1", + "input": "Hello world", + "voice": "echo", + "speed": 1.5, + "response_format": "opus" + } + """; + + const string Output = "fake-audio-bytes"; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1"); + + var response = await client.GetAudioAsync("Hello world", new() + { + VoiceId = "echo", + RawRepresentationFactory = (s) => + new SpeechGenerationOptions + { + SpeedRatio = 1.5f, + ResponseFormat = GeneratedSpeechFormat.Opus + } + }); + + Assert.NotNull(response); + Assert.Single(response.Contents); + var content = Assert.IsType(response.Contents[0]); + Assert.Equal("audio/opus", content.MediaType); + } + + [Fact] + public async Task GetAudioAsync_Cancelled_Throws() + { + using HttpClient httpClient = new(); + using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1"); + + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAsync(() + => client.GetAudioAsync("Hello world", cancellationToken: cancellationTokenSource.Token)); + } + + [Fact] + public async Task GetStreamingAudioAsync_Cancelled_Throws() + { + using HttpClient httpClient = new(); + using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1"); + + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAsync(() + => client + .GetStreamingAudioAsync("Hello world", cancellationToken: cancellationTokenSource.Token) + .GetAsyncEnumerator() + .MoveNextAsync() + .AsTask()); + } + + [Fact] + public async Task GetStreamingAudioAsync_FallsBackToNonStreaming() + { + const string Input = """ + { + "model": "tts-1", + "input": "Hello streaming", + "voice": "alloy" + } + """; + + const string Output = "fake-audio-bytes"; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using ITextToSpeechClient client = CreateTextToSpeechClient(httpClient, "tts-1"); + + int updateCount = 0; + await foreach (var update in client.GetStreamingAudioAsync("Hello streaming")) + { + updateCount++; + Assert.NotNull(update); + Assert.NotNull(update.RawRepresentation); + Assert.Equal(TextToSpeechResponseUpdateKind.AudioUpdated, update.Kind); + var content = update.Contents.OfType().Single(); + Assert.Equal("audio/mpeg", content.MediaType); + Assert.True(content.Data.Length > 0); + } + + Assert.Equal(1, updateCount); + } + + private static ITextToSpeechClient CreateTextToSpeechClient(HttpClient httpClient, string modelId) => + new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) + .GetAudioClient(modelId) + .AsITextToSpeechClient(); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 4e927c73b4d..4a88883cdbf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -1668,6 +1668,9 @@ public async Task ClonesChatOptionsAndResetContinuationTokenForBackgroundRespons [InlineData("invoke_agent")] [InlineData("invoke_agent my_agent")] [InlineData("invoke_agent ")] + [InlineData("invoke_workflow")] + [InlineData("invoke_workflow my_workflow")] + [InlineData("invoke_workflow ")] public async Task DoesNotCreateOrchestrateToolsSpanWhenInvokeAgentIsParent(string displayName) { string agentSourceName = Guid.NewGuid().ToString(); @@ -1713,8 +1716,10 @@ public async Task DoesNotCreateOrchestrateToolsSpanWhenInvokeAgentIsParent(strin Assert.All(childActivities, activity => Assert.Same(invokeAgent, activity.Parent)); } - [Fact] - public async Task StreamingPreservesTraceContextWhenInvokeAgentWithNameIsParent() + [Theory] + [InlineData("invoke_agent MyAgent(agent-123)")] + [InlineData("invoke_workflow MyWorkflow(workflow-123)")] + public async Task StreamingPreservesTraceContextWhenInvokeAgentWithNameIsParent(string displayName) { string agentSourceName = Guid.NewGuid().ToString(); string clientSourceName = Guid.NewGuid().ToString(); @@ -1750,7 +1755,7 @@ public async Task StreamingPreservesTraceContextWhenInvokeAgentWithNameIsParent( }; using var agentSource = new ActivitySource(agentSourceName); - using var invokeAgentActivity = agentSource.StartActivity("invoke_agent MyAgent(agent-123)"); + using var invokeAgentActivity = agentSource.StartActivity(displayName); Assert.NotNull(invokeAgentActivity); await foreach (var update in client.GetStreamingResponseAsync( @@ -1764,7 +1769,7 @@ [new ChatMessage(ChatRole.User, "hello")], options)) var chatActivities = activities.Where(a => a.DisplayName.StartsWith("chat", StringComparison.Ordinal)).ToList(); Assert.Equal(2, chatActivities.Count); - // All child activities must share the same trace as invoke_agent + // All child activities must share the same trace as the parent activity var nonAgentActivities = activities.Where(a => a != invokeAgentActivity).ToList(); Assert.All(nonAgentActivities, a => Assert.Equal(invokeAgentActivity.TraceId, a.TraceId)); @@ -1774,6 +1779,9 @@ [new ChatMessage(ChatRole.User, "hello")], options)) [InlineData("invoke_agen")] [InlineData("invoke_agent_extra")] [InlineData("invoke_agentx")] + [InlineData("invoke_workflo")] + [InlineData("invoke_workflow_extra")] + [InlineData("invoke_workflowx")] public async Task CreatesOrchestrateToolsSpanWhenParentIsNotInvokeAgent(string displayName) { string agentSourceName = Guid.NewGuid().ToString(); @@ -1813,8 +1821,10 @@ public async Task CreatesOrchestrateToolsSpanWhenParentIsNotInvokeAgent(string d Assert.Contains(activities, a => a.DisplayName == "orchestrate_tools"); } - [Fact] - public async Task UsesAgentActivitySourceWhenInvokeAgentIsParent() + [Theory] + [InlineData("invoke_agent")] + [InlineData("invoke_workflow")] + public async Task UsesAgentActivitySourceWhenInvokeAgentIsParent(string displayName) { string agentSourceName = Guid.NewGuid().ToString(); string clientSourceName = Guid.NewGuid().ToString(); @@ -1844,7 +1854,7 @@ public async Task UsesAgentActivitySourceWhenInvokeAgentIsParent() .Build(); using (var agentSource = new ActivitySource(agentSourceName)) - using (var invokeAgentActivity = agentSource.StartActivity("invoke_agent")) + using (var invokeAgentActivity = agentSource.StartActivity(displayName)) { Assert.NotNull(invokeAgentActivity); await InvokeAndAssertAsync(options, plan, configurePipeline: configure); @@ -1856,14 +1866,15 @@ public async Task UsesAgentActivitySourceWhenInvokeAgentIsParent() } public static IEnumerable SensitiveDataPropagatesFromAgentActivityWhenInvokeAgentIsParent_MemberData() => + from operationName in new[] { "invoke_agent", "invoke_workflow" } from invokeAgentSensitiveData in new bool?[] { null, false, true } from innerOpenTelemetryChatClient in new bool?[] { null, false, true } - select new object?[] { invokeAgentSensitiveData, innerOpenTelemetryChatClient }; + select new object?[] { operationName, invokeAgentSensitiveData, innerOpenTelemetryChatClient }; [Theory] [MemberData(nameof(SensitiveDataPropagatesFromAgentActivityWhenInvokeAgentIsParent_MemberData))] public async Task SensitiveDataPropagatesFromAgentActivityWhenInvokeAgentIsParent( - bool? invokeAgentSensitiveData, bool? innerOpenTelemetryChatClient) + string operationName, bool? invokeAgentSensitiveData, bool? innerOpenTelemetryChatClient) { string agentSourceName = Guid.NewGuid().ToString(); string clientSourceName = Guid.NewGuid().ToString(); @@ -1890,7 +1901,7 @@ public async Task SensitiveDataPropagatesFromAgentActivityWhenInvokeAgentIsParen .Build(); using (var agentSource = new ActivitySource(agentSourceName)) - using (var invokeAgentActivity = agentSource.StartActivity("invoke_agent")) + using (var invokeAgentActivity = agentSource.StartActivity(operationName)) { if (invokeAgentSensitiveData is not null) { diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs index ee9443b6522..d0cb1572928 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs @@ -10,6 +10,8 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Diagnostics.Metrics.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using OpenTelemetry.Trace; using Xunit; @@ -998,5 +1000,65 @@ public async Task StreamingChunkMetrics_NotRecordedForNonStreamingCalls() private sealed class NonSerializableAIContent : AIContent; private static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", " ").Trim(); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExceptionLogged_Async(bool streaming) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + + var expectedException = new InvalidOperationException("test exception message"); + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => throw expectedException, + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => throw expectedException, + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("testservice", new Uri("http://localhost:12345"), "testmodel") : + null, + }; + + using var chatClient = innerClient + .AsBuilder() + .UseOpenTelemetry(loggerFactory, sourceName) + .Build(); + + if (streaming) + { + await Assert.ThrowsAsync(async () => + { + await foreach (var update in chatClient.GetStreamingResponseAsync([new(ChatRole.User, "Hello")])) + { + _ = update; + } + }); + } + else + { + await Assert.ThrowsAsync(() => + chatClient.GetResponseAsync([new(ChatRole.User, "Hello")])); + } + + var activity = Assert.Single(activities); + + // Existing error behavior is preserved + Assert.Equal(expectedException.GetType().FullName, activity.GetTagItem("error.type")); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + + // Exception is logged via ILogger + var logEntry = Assert.Single(collector.GetSnapshot()); + Assert.Equal("gen_ai.client.operation.exception", logEntry.Id.Name); + Assert.Equal(LogLevel.Warning, logEntry.Level); + Assert.Same(expectedException, logEntry.Exception); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs index b533cbc00c4..02d0f482274 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs @@ -94,4 +94,48 @@ public async Task ExpectedInformationLogged_Async(string? perRequestModelId, boo Assert.True(activity.Duration.TotalMilliseconds > 0); } + + [Fact] + public async Task ExceptionLogged_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + + var expectedException = new InvalidOperationException("test exception message"); + + using var innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = (values, options, cancellationToken) => throw expectedException, + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(EmbeddingGeneratorMetadata) ? new EmbeddingGeneratorMetadata("testservice", new Uri("http://localhost:12345"), "testmodel") : + null, + }; + + using var generator = innerGenerator + .AsBuilder() + .UseOpenTelemetry(loggerFactory, sourceName) + .Build(); + + await Assert.ThrowsAsync(() => + generator.GenerateAsync(["hello"])); + + var activity = Assert.Single(activities); + + // Existing error behavior is preserved + Assert.Equal(expectedException.GetType().FullName, activity.GetTagItem("error.type")); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + + // Exception is logged via ILogger + var logEntry = Assert.Single(collector.GetSnapshot()); + Assert.Equal("gen_ai.client.operation.exception", logEntry.Id.Name); + Assert.Equal(LogLevel.Warning, logEntry.Level); + Assert.Same(expectedException, logEntry.Exception); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Files/OpenTelemetryHostedFileClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Files/OpenTelemetryHostedFileClientTests.cs index bd8ee40968f..88396f31079 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Files/OpenTelemetryHostedFileClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Files/OpenTelemetryHostedFileClientTests.cs @@ -8,6 +8,8 @@ using System.Diagnostics; using System.IO; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using OpenTelemetry.Trace; using Xunit; @@ -260,6 +262,9 @@ public async Task UploadAsync_OnError_SetsErrorStatus() .AddInMemoryExporter(activities) .Build(); + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using var innerClient = new TestHostedFileClient { UploadAsyncCallback = (stream, mediaType, fileName, options, ct) => @@ -269,7 +274,7 @@ public async Task UploadAsync_OnError_SetsErrorStatus() using var client = innerClient .AsBuilder() - .UseOpenTelemetry(sourceName: sourceName) + .UseOpenTelemetry(loggerFactory, sourceName) .Build(); using var stream = new MemoryStream(new byte[] { 1 }); @@ -279,6 +284,7 @@ public async Task UploadAsync_OnError_SetsErrorStatus() Assert.Equal(ActivityStatusCode.Error, activity.Status); Assert.Equal("upload failed", activity.StatusDescription); Assert.Equal(typeof(InvalidOperationException).FullName, activity.GetTagItem("error.type")); + AssertExceptionLogged(collector, typeof(InvalidOperationException)); } [Fact] @@ -291,6 +297,9 @@ public async Task ListFilesAsync_OnIterationError_SetsErrorStatus() .AddInMemoryExporter(activities) .Build(); + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using var innerClient = new TestHostedFileClient { ListFilesAsyncCallback = (options, ct) => ThrowOnSecondItem(), @@ -306,7 +315,7 @@ static async IAsyncEnumerable ThrowOnSecondItem() using var client = innerClient .AsBuilder() - .UseOpenTelemetry(sourceName: sourceName) + .UseOpenTelemetry(loggerFactory, sourceName) .Build(); await Assert.ThrowsAsync(async () => @@ -321,6 +330,7 @@ await Assert.ThrowsAsync(async () => Assert.Equal(ActivityStatusCode.Error, activity.Status); Assert.Equal(typeof(InvalidOperationException).FullName, activity.GetTagItem("error.type")); Assert.Equal(1, activity.GetTagItem("files.list.count")); + AssertExceptionLogged(collector, typeof(InvalidOperationException)); } [Fact] @@ -378,6 +388,9 @@ public async Task DownloadAsync_OnError_SetsErrorStatus() .AddInMemoryExporter(activities) .Build(); + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using var innerClient = new TestHostedFileClient { DownloadAsyncCallback = (fileId, options, ct) => @@ -387,7 +400,7 @@ public async Task DownloadAsync_OnError_SetsErrorStatus() using var client = innerClient .AsBuilder() - .UseOpenTelemetry(sourceName: sourceName) + .UseOpenTelemetry(loggerFactory, sourceName) .Build(); await Assert.ThrowsAsync(() => client.DownloadAsync("file-1")); @@ -397,6 +410,7 @@ public async Task DownloadAsync_OnError_SetsErrorStatus() Assert.Equal(ActivityStatusCode.Error, activity.Status); Assert.Equal("download failed", activity.StatusDescription); Assert.Equal(typeof(InvalidOperationException).FullName, activity.GetTagItem("error.type")); + AssertExceptionLogged(collector, typeof(InvalidOperationException)); } [Fact] @@ -409,6 +423,9 @@ public async Task DeleteAsync_OnError_SetsErrorStatus() .AddInMemoryExporter(activities) .Build(); + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using var innerClient = new TestHostedFileClient { DeleteAsyncCallback = (fileId, options, ct) => @@ -418,7 +435,7 @@ public async Task DeleteAsync_OnError_SetsErrorStatus() using var client = innerClient .AsBuilder() - .UseOpenTelemetry(sourceName: sourceName) + .UseOpenTelemetry(loggerFactory, sourceName) .Build(); await Assert.ThrowsAsync(() => client.DeleteAsync("file-1")); @@ -428,6 +445,7 @@ public async Task DeleteAsync_OnError_SetsErrorStatus() Assert.Equal(ActivityStatusCode.Error, activity.Status); Assert.Equal("delete failed", activity.StatusDescription); Assert.Equal(typeof(InvalidOperationException).FullName, activity.GetTagItem("error.type")); + AssertExceptionLogged(collector, typeof(InvalidOperationException)); } [Fact] @@ -440,6 +458,9 @@ public async Task GetFileInfoAsync_OnError_SetsErrorStatus() .AddInMemoryExporter(activities) .Build(); + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using var innerClient = new TestHostedFileClient { GetFileInfoAsyncCallback = (fileId, options, ct) => @@ -449,7 +470,7 @@ public async Task GetFileInfoAsync_OnError_SetsErrorStatus() using var client = innerClient .AsBuilder() - .UseOpenTelemetry(sourceName: sourceName) + .UseOpenTelemetry(loggerFactory, sourceName) .Build(); await Assert.ThrowsAsync(() => client.GetFileInfoAsync("file-1")); @@ -459,6 +480,7 @@ public async Task GetFileInfoAsync_OnError_SetsErrorStatus() Assert.Equal(ActivityStatusCode.Error, activity.Status); Assert.Equal("get info failed", activity.StatusDescription); Assert.Equal(typeof(InvalidOperationException).FullName, activity.GetTagItem("error.type")); + AssertExceptionLogged(collector, typeof(InvalidOperationException)); } [Fact] @@ -576,6 +598,15 @@ public async Task AdditionalProperties_NotTaggedWhenSensitiveDataDisabled() serviceType == typeof(HostedFileClientMetadata) ? new HostedFileClientMetadata("testprovider", new Uri("http://localhost:8080/files")) : null; + private static void AssertExceptionLogged(FakeLogCollector collector, Type expectedExceptionType) + { + var logEntry = Assert.Single(collector.GetSnapshot()); + Assert.Equal("gen_ai.client.operation.exception", logEntry.Id.Name); + Assert.Equal(LogLevel.Warning, logEntry.Level); + Assert.NotNull(logEntry.Exception); + Assert.IsType(expectedExceptionType, logEntry.Exception); + } + private sealed class TestDownloadStream : HostedFileDownloadStream { private readonly MemoryStream _inner; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs index 30fc4ae4849..5a81164c5f4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs @@ -7,6 +7,8 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using OpenTelemetry.Trace; using Xunit; @@ -165,4 +167,48 @@ public async Task ExpectedInformationLogged_Async(bool enableSensitiveData) static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", " ").Trim(); } + + [Fact] + public async Task ExceptionLogged_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + + var expectedException = new InvalidOperationException("test exception message"); + + using var innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => throw expectedException, + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(ImageGeneratorMetadata) ? new ImageGeneratorMetadata("testservice", new Uri("http://localhost:12345"), "testmodel") : + null, + }; + + using var g = innerGenerator + .AsBuilder() + .UseOpenTelemetry(loggerFactory, sourceName) + .Build(); + + await Assert.ThrowsAsync(() => + g.GenerateAsync(new ImageGenerationRequest { Prompt = "a cat" })); + + var activity = Assert.Single(activities); + + // Existing error behavior is preserved + Assert.Equal(expectedException.GetType().FullName, activity.GetTagItem("error.type")); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + + // Exception is logged via ILogger + var logEntry = Assert.Single(collector.GetSnapshot()); + Assert.Equal("gen_ai.client.operation.exception", logEntry.Id.Name); + Assert.Equal(LogLevel.Warning, logEntry.Level); + Assert.Same(expectedException, logEntry.Exception); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj index 3c266cc872d..c92459ef493 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj @@ -23,8 +23,10 @@ + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/FunctionInvokingRealtimeClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/FunctionInvokingRealtimeClientTests.cs new file mode 100644 index 00000000000..0ea2d47bf8e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/FunctionInvokingRealtimeClientTests.cs @@ -0,0 +1,685 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. +#pragma warning disable SA1204 // Static elements should appear before instance elements + +namespace Microsoft.Extensions.AI; + +public class FunctionInvokingRealtimeClientTests +{ + [Fact] + public void Ctor_NullArgs_Throws() + { + Assert.Throws("innerClient", () => new FunctionInvokingRealtimeClient(null!)); + } + + [Fact] + public void Properties_DefaultValues() + { + using var client = CreateClient(); + + Assert.False(client.IncludeDetailedErrors); + Assert.False(client.AllowConcurrentInvocation); + Assert.Equal(40, client.MaximumIterationsPerRequest); + Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); + Assert.Null(client.AdditionalTools); + Assert.False(client.TerminateOnUnknownCalls); + Assert.Null(client.FunctionInvoker); + } + + [Fact] + public void MaximumIterationsPerRequest_InvalidValue_Throws() + { + using var client = CreateClient(); + + Assert.Throws("value", () => client.MaximumIterationsPerRequest = 0); + Assert.Throws("value", () => client.MaximumIterationsPerRequest = -1); + } + + [Fact] + public void MaximumConsecutiveErrorsPerRequest_InvalidValue_Throws() + { + using var client = CreateClient(); + + Assert.Throws("value", () => client.MaximumConsecutiveErrorsPerRequest = -1); + + // 0 is valid (means immediately rethrow on any error) + client.MaximumConsecutiveErrorsPerRequest = 0; + Assert.Equal(0, client.MaximumConsecutiveErrorsPerRequest); + } + + [Fact] + public async Task GetStreamingResponseAsync_NoFunctionCalls_PassesThrough() + { + var serverMessages = new RealtimeServerMessage[] + { + new() { Type = RealtimeServerMessageType.ResponseCreated, MessageId = "evt_001" }, + new() { Type = RealtimeServerMessageType.ResponseDone, MessageId = "evt_002" }, + }; + + await using var inner = new TestRealtimeClientSession + { + GetStreamingResponseAsyncCallback = (ct) => YieldMessages(serverMessages, ct), + }; + using var client = CreateClient(inner); + await using var session = await client.CreateSessionAsync(); + + var received = new List(); + await foreach (var msg in session.GetStreamingResponseAsync()) + { + received.Add(msg); + } + + Assert.Equal(2, received.Count); + Assert.Equal("evt_001", received[0].MessageId); + Assert.Equal("evt_002", received[1].MessageId); + } + + [Fact] + public async Task GetStreamingResponseAsync_FunctionCall_InvokesAndInjectsResult() + { + AIFunction getWeather = AIFunctionFactory.Create( + (string city) => $"Sunny in {city}", + "get_weather", + "Gets the weather"); + + var injectedMessages = new List(); + await using var inner = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Tools = [getWeather] }, + GetStreamingResponseAsyncCallback = (ct) => YieldMessages( + [ + CreateFunctionCallOutputItemMessage("call_001", "get_weather", new Dictionary { ["city"] = "Seattle" }), + ], ct), + SendAsyncCallback = (msg, _) => + { + injectedMessages.Add(msg); + return Task.CompletedTask; + }, + }; + + using var client = CreateClient(inner); + await using var session = await client.CreateSessionAsync(); + + var received = new List(); + await foreach (var msg in session.GetStreamingResponseAsync()) + { + received.Add(msg); + } + + // The function call message should be yielded to the consumer + Assert.Single(received); + + // Function result + response.create should be injected + Assert.Equal(2, injectedMessages.Count); + + // First injected: conversation.item.create with function result + var resultMsg = Assert.IsType(injectedMessages[0]); + Assert.NotNull(resultMsg.Item); + var functionResult = Assert.IsType(resultMsg.Item.Contents[0]); + Assert.Equal("call_001", functionResult.CallId); + Assert.Contains("Sunny in Seattle", functionResult.Result?.ToString()); + + // Second injected: response.create (no hardcoded modalities) + var responseCreate = Assert.IsType(injectedMessages[1]); + Assert.Null(responseCreate.OutputModalities); + } + + [Fact] + public async Task GetStreamingResponseAsync_FunctionCall_FromAdditionalTools() + { + AIFunction getWeather = AIFunctionFactory.Create( + (string city) => $"Rainy in {city}", + "get_weather", + "Gets weather"); + + var injectedMessages = new List(); + await using var inner = new TestRealtimeClientSession + { + GetStreamingResponseAsyncCallback = (ct) => YieldMessages( + [ + CreateFunctionCallOutputItemMessage("call_002", "get_weather", new Dictionary { ["city"] = "London" }), + ], ct), + SendAsyncCallback = (msg, _) => + { + injectedMessages.Add(msg); + return Task.CompletedTask; + }, + }; + + using var client = CreateClient(inner); + client.AdditionalTools = [getWeather]; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in session.GetStreamingResponseAsync()) + { + // consume + } + + Assert.Equal(2, injectedMessages.Count); + var resultMsg = Assert.IsType(injectedMessages[0]); + var functionResult = Assert.IsType(resultMsg.Item.Contents[0]); + Assert.Contains("Rainy in London", functionResult.Result?.ToString()); + } + + [Fact] + public async Task GetStreamingResponseAsync_MaxIterations_StopsInvoking() + { + int invocationCount = 0; + AIFunction countFunc = AIFunctionFactory.Create( + () => + { + invocationCount++; + return "result"; + }, + "counter", + "Counts"); + + var messages = Enumerable.Range(0, 5).Select(i => + CreateFunctionCallOutputItemMessage($"call_{i}", "counter", null)).ToList(); + + await using var inner = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Tools = [countFunc] }, + GetStreamingResponseAsyncCallback = (ct) => YieldMessages(messages, ct), + SendAsyncCallback = (_, _) => Task.CompletedTask, + }; + + using var client = CreateClient(inner); + client.MaximumIterationsPerRequest = 2; + await using var session = await client.CreateSessionAsync(); + + var received = new List(); + await foreach (var msg in session.GetStreamingResponseAsync()) + { + received.Add(msg); + } + + // All 5 messages should be yielded + Assert.Equal(5, received.Count); + + // But only 2 should have been invoked + Assert.Equal(2, invocationCount); + } + + [Fact] + public async Task GetStreamingResponseAsync_FunctionInvoker_CustomDelegate() + { + var customInvoked = false; + AIFunction myFunc = AIFunctionFactory.Create( + () => "default", + "my_func", + "Test"); + + await using var inner = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Tools = [myFunc] }, + GetStreamingResponseAsyncCallback = (ct) => YieldMessages( + [ + CreateFunctionCallOutputItemMessage("call_custom", "my_func", null), + ], ct), + SendAsyncCallback = (_, _) => Task.CompletedTask, + }; + + using var client = CreateClient(inner); + client.FunctionInvoker = (context, ct) => + { + customInvoked = true; + return new ValueTask("custom_result"); + }; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in session.GetStreamingResponseAsync()) + { + // consume + } + + Assert.True(customInvoked); + } + + [Fact] + public async Task GetStreamingResponseAsync_UnknownFunction_SendsErrorByDefault() + { + var injectedMessages = new List(); + await using var inner = new TestRealtimeClientSession + { + GetStreamingResponseAsyncCallback = (ct) => YieldMessages( + [ + CreateFunctionCallOutputItemMessage("call_unknown", "nonexistent_func", null), + ], ct), + SendAsyncCallback = (msg, _) => + { + injectedMessages.Add(msg); + return Task.CompletedTask; + }, + }; + + using var client = CreateClient(inner); + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in session.GetStreamingResponseAsync()) + { + // consume + } + + // Should inject error result + response.create + Assert.Equal(2, injectedMessages.Count); + var resultMsg = Assert.IsType(injectedMessages[0]); + var functionResult = Assert.IsType(resultMsg.Item.Contents[0]); + Assert.Contains("not found", functionResult.Result?.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetStreamingResponseAsync_FunctionError_IncludesDetailedErrors() + { + AIFunction failFunc = AIFunctionFactory.Create( + new Func(() => throw new InvalidOperationException("Something broke")), + "fail_func", + "Fails"); + + var injectedMessages = new List(); + await using var inner = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Tools = [failFunc] }, + GetStreamingResponseAsyncCallback = (ct) => YieldMessages( + [ + CreateFunctionCallOutputItemMessage("call_fail", "fail_func", null), + ], ct), + SendAsyncCallback = (msg, _) => + { + injectedMessages.Add(msg); + return Task.CompletedTask; + }, + }; + + using var client = CreateClient(inner); + client.IncludeDetailedErrors = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in session.GetStreamingResponseAsync()) + { + // consume + } + + Assert.Equal(2, injectedMessages.Count); + var resultMsg = Assert.IsType(injectedMessages[0]); + var functionResult = Assert.IsType(resultMsg.Item.Contents[0]); + Assert.Contains("Something broke", functionResult.Result?.ToString()); + } + + [Fact] + public async Task GetStreamingResponseAsync_FunctionError_HidesDetailsWhenNotEnabled() + { + AIFunction failFunc = AIFunctionFactory.Create( + new Func(() => throw new InvalidOperationException("Secret error info")), + "fail_func", + "Fails"); + + var injectedMessages = new List(); + await using var inner = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Tools = [failFunc] }, + GetStreamingResponseAsyncCallback = (ct) => YieldMessages( + [ + CreateFunctionCallOutputItemMessage("call_fail2", "fail_func", null), + ], ct), + SendAsyncCallback = (msg, _) => + { + injectedMessages.Add(msg); + return Task.CompletedTask; + }, + }; + + using var client = CreateClient(inner); + client.IncludeDetailedErrors = false; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in session.GetStreamingResponseAsync()) + { + // consume + } + + var resultMsg = Assert.IsType(injectedMessages[0]); + var functionResult = Assert.IsType(resultMsg.Item.Contents[0]); + Assert.DoesNotContain("Secret error info", functionResult.Result?.ToString()); + Assert.Contains("failed", functionResult.Result?.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetService_ReturnsSelf() + { + await using var inner = new TestRealtimeClientSession(); + using var client = CreateClient(inner); + await using var session = await client.CreateSessionAsync(); + + Assert.Same(client, client.GetService(typeof(FunctionInvokingRealtimeClient))); + Assert.Same(session, session.GetService(typeof(IRealtimeClientSession))); + Assert.Same(inner, session.GetService(typeof(TestRealtimeClientSession))); + } + + [Fact] + public async Task GetStreamingResponseAsync_TerminateOnUnknownCalls_StopsLoop() + { + var injectedMessages = new List(); + await using var inner = new TestRealtimeClientSession + { + GetStreamingResponseAsyncCallback = (ct) => YieldMessages( + [ + CreateFunctionCallOutputItemMessage("call_unknown", "nonexistent_func", null), + new RealtimeServerMessage { Type = RealtimeServerMessageType.ResponseDone, MessageId = "should_not_reach" }, + ], ct), + SendAsyncCallback = (msg, _) => + { + injectedMessages.Add(msg); + return Task.CompletedTask; + }, + }; + + using var client = CreateClient(inner); + client.TerminateOnUnknownCalls = true; + await using var session = await client.CreateSessionAsync(); + + var received = new List(); + await foreach (var msg in session.GetStreamingResponseAsync()) + { + received.Add(msg); + } + + // The function call message should be yielded, then the loop terminates + Assert.Single(received); + + // No function results should be injected since we're terminating + Assert.Empty(injectedMessages); + } + + [Fact] + public async Task GetStreamingResponseAsync_TerminateOnUnknownCalls_False_SendsError() + { + var injectedMessages = new List(); + await using var inner = new TestRealtimeClientSession + { + GetStreamingResponseAsyncCallback = (ct) => YieldMessages( + [ + CreateFunctionCallOutputItemMessage("call_unknown", "nonexistent_func", null), + ], ct), + SendAsyncCallback = (msg, _) => + { + injectedMessages.Add(msg); + return Task.CompletedTask; + }, + }; + + using var client = CreateClient(inner); + client.TerminateOnUnknownCalls = false; + await using var session = await client.CreateSessionAsync(); + + var received = new List(); + await foreach (var msg in session.GetStreamingResponseAsync()) + { + received.Add(msg); + } + + Assert.Single(received); + + // Error result + response.create should be injected (default behavior) + Assert.Equal(2, injectedMessages.Count); + var resultMsg = Assert.IsType(injectedMessages[0]); + var functionResult = Assert.IsType(resultMsg.Item.Contents[0]); + Assert.Contains("not found", functionResult.Result?.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetStreamingResponseAsync_ConcurrentInvocation_InvokesInParallel() + { + int concurrentCount = 0; + int maxConcurrency = 0; + object lockObj = new(); + + AIFunction slowFunc = AIFunctionFactory.Create( + async () => + { + int current; + lock (lockObj) + { + concurrentCount++; + current = concurrentCount; + if (current > maxConcurrency) + { + maxConcurrency = current; + } + } + + await Task.Delay(50).ConfigureAwait(false); + + lock (lockObj) + { + concurrentCount--; + } + + return "done"; + }, + "slow_func", + "Slow"); + + // Create two function call messages in the same response + var msg1 = CreateFunctionCallOutputItemMessage("call_a", "slow_func", null); + var msg2 = CreateFunctionCallOutputItemMessage("call_b", "slow_func", null); + + // Combine both into a single ResponseOutputItem with multiple function calls + var combinedItem = new RealtimeConversationItem( + [ + new FunctionCallContent("call_a", "slow_func"), + new FunctionCallContent("call_b", "slow_func"), + ], "item_combined"); + + var combinedMessage = new ResponseOutputItemRealtimeServerMessage(RealtimeServerMessageType.ResponseOutputItemDone) + { + ResponseId = "resp_combined", + OutputIndex = 0, + Item = combinedItem, + }; + + await using var inner = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Tools = [slowFunc] }, + GetStreamingResponseAsyncCallback = (ct) => YieldMessages([combinedMessage], ct), + SendAsyncCallback = (_, _) => Task.CompletedTask, + }; + + using var client = CreateClient(inner); + client.AllowConcurrentInvocation = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in session.GetStreamingResponseAsync()) + { + // consume + } + + Assert.True(maxConcurrency >= 1, "At least one invocation should have occurred"); + } + + [Fact] + public async Task GetStreamingResponseAsync_ConsecutiveErrors_ExceedsLimit_Throws() + { + int callCount = 0; + AIFunction failFunc = AIFunctionFactory.Create( + new Func(() => + { + callCount++; + throw new InvalidOperationException($"Error #{callCount}"); + }), + "fail_func", + "Fails"); + + // Create messages that will trigger multiple error iterations + // Each time the function is called, it fails, and the error count increases + var messages = new List + { + CreateFunctionCallOutputItemMessage("call_1", "fail_func", null), + CreateFunctionCallOutputItemMessage("call_2", "fail_func", null), + CreateFunctionCallOutputItemMessage("call_3", "fail_func", null), + CreateFunctionCallOutputItemMessage("call_4", "fail_func", null), + }; + + await using var inner = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Tools = [failFunc] }, + GetStreamingResponseAsyncCallback = (ct) => YieldMessages(messages, ct), + SendAsyncCallback = (_, _) => Task.CompletedTask, + }; + + using var client = CreateClient(inner); + client.MaximumConsecutiveErrorsPerRequest = 1; + await using var session = await client.CreateSessionAsync(); + + // Should eventually throw after exceeding the consecutive error limit + await Assert.ThrowsAsync(async () => + { + await foreach (var msg in session.GetStreamingResponseAsync()) + { + // consume + } + }); + } + + [Fact] + public void UseFunctionInvocation_NullBuilder_Throws() + { + Assert.Throws("builder", () => + ((RealtimeClientBuilder)null!).UseFunctionInvocation()); + } + + [Fact] + public async Task UseFunctionInvocation_ConfigureCallback_IsInvoked() + { + await using var inner = new TestRealtimeClientSession(); + using var innerClient = new TestRealtimeClient(inner); + var builder = new RealtimeClientBuilder(innerClient); + + bool configured = false; + builder.UseFunctionInvocation(configure: client => + { + configured = true; + client.IncludeDetailedErrors = true; + client.MaximumIterationsPerRequest = 10; + }); + + using var pipeline = builder.Build(); + Assert.True(configured); + + await using var session = await pipeline.CreateSessionAsync(); + + var funcClient = pipeline.GetService(typeof(FunctionInvokingRealtimeClient)); + Assert.NotNull(funcClient); + var typedClient = Assert.IsType(funcClient); + Assert.True(typedClient.IncludeDetailedErrors); + Assert.Equal(10, typedClient.MaximumIterationsPerRequest); + } + + [Fact] + public async Task GetStreamingResponseAsync_NonInvocableTool_TerminatesLoop() + { + // Create a non-invocable AIFunctionDeclaration (not an AIFunction) + var schema = JsonDocument.Parse("""{"type":"object","properties":{"x":{"type":"string"}}}""").RootElement; + var declaration = AIFunctionFactory.CreateDeclaration("my_declaration", "A non-invocable tool", schema); + + var injectedMessages = new List(); + await using var inner = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Tools = [declaration] }, + GetStreamingResponseAsyncCallback = (ct) => YieldMessages( + [ + CreateFunctionCallOutputItemMessage("call_decl", "my_declaration", null), + new RealtimeServerMessage { Type = RealtimeServerMessageType.ResponseDone, MessageId = "should_not_reach" }, + ], ct), + SendAsyncCallback = (msg, _) => + { + injectedMessages.Add(msg); + return Task.CompletedTask; + }, + }; + + using var client = CreateClient(inner); + await using var session = await client.CreateSessionAsync(); + + var received = new List(); + await foreach (var msg in session.GetStreamingResponseAsync()) + { + received.Add(msg); + } + + // The function call message should be yielded, then loop terminates + // because the tool is a declaration-only (non-invocable) + Assert.Single(received); + + // No results should be injected since we terminated + Assert.Empty(injectedMessages); + } + + #region Helpers + +#pragma warning disable CA2000 // Dispose objects before losing scope - ownership transferred to FunctionInvokingRealtimeClient + private static FunctionInvokingRealtimeClient CreateClient(IRealtimeClientSession? session = null) + { + return new FunctionInvokingRealtimeClient(new TestRealtimeClient(session ?? new TestRealtimeClientSession())); + } +#pragma warning restore CA2000 + + private static ResponseOutputItemRealtimeServerMessage CreateFunctionCallOutputItemMessage( + string callId, string functionName, IDictionary? arguments) + { + var functionCallContent = new FunctionCallContent(callId, functionName, arguments); + var item = new RealtimeConversationItem([functionCallContent], $"item_{callId}"); + + return new ResponseOutputItemRealtimeServerMessage(RealtimeServerMessageType.ResponseOutputItemDone) + { + ResponseId = $"resp_{callId}", + OutputIndex = 0, + Item = item, + }; + } + + private static async IAsyncEnumerable YieldMessages( + IList messages, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = cancellationToken; + foreach (var msg in messages) + { + await Task.CompletedTask.ConfigureAwait(false); + yield return msg; + } + } + + #endregion + + private sealed class TestRealtimeClient : IRealtimeClient + { + private readonly IRealtimeClientSession _session; + + public TestRealtimeClient(IRealtimeClientSession session) + { + _session = session; + } + + public Task CreateSessionAsync(RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(_session); + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : _session.GetService(serviceType, serviceKey); + + public void Dispose() + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/LoggingRealtimeClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/LoggingRealtimeClientTests.cs new file mode 100644 index 00000000000..0eac43d40bc --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/LoggingRealtimeClientTests.cs @@ -0,0 +1,481 @@ +// 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.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class LoggingRealtimeClientTests +{ + [Fact] + public async Task LoggingRealtimeClient_InvalidArgs_Throws() + { + await using var innerSession = new TestRealtimeClientSession(); + using var innerClient = new TestRealtimeClient(innerSession); + Assert.Throws("innerClient", () => new LoggingRealtimeClient(null!, NullLogger.Instance)); + Assert.Throws("logger", () => new LoggingRealtimeClient(innerClient, null!)); + } + + [Fact] + public async Task UseLogging_AvoidsInjectingNopSession() + { + await using var innerSession = new TestRealtimeClientSession(); + + using var c1 = new TestRealtimeClient(innerSession); + using var pipeline1 = c1.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build(); + await using var s1 = await pipeline1.CreateSessionAsync(); + Assert.Null(pipeline1.GetService(typeof(LoggingRealtimeClient))); + Assert.Same(innerSession, s1.GetService(typeof(IRealtimeClientSession))); + + using var factory = LoggerFactory.Create(b => b.AddFakeLogging()); + using var c2 = new TestRealtimeClient(innerSession); + using var pipeline2 = c2.AsBuilder().UseLogging(factory).Build(); + await using var s2 = await pipeline2.CreateSessionAsync(); + Assert.NotNull(pipeline2.GetService(typeof(LoggingRealtimeClient))); + + ServiceCollection c = new(); + c.AddFakeLogging(); + var services = c.BuildServiceProvider(); + using var c3 = new TestRealtimeClient(innerSession); + using var pipeline3 = c3.AsBuilder().UseLogging().Build(services); + await using var s3 = await pipeline3.CreateSessionAsync(); + Assert.NotNull(pipeline3.GetService(typeof(LoggingRealtimeClient))); + using var c4 = new TestRealtimeClient(innerSession); + using var pipeline4 = c4.AsBuilder().UseLogging(null).Build(services); + await using var s4 = await pipeline4.CreateSessionAsync(); + Assert.NotNull(pipeline4.GetService(typeof(LoggingRealtimeClient))); + using var c5 = new TestRealtimeClient(innerSession); + using var pipeline5 = c5.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build(services); + await using var s5 = await pipeline5.CreateSessionAsync(); + Assert.Null(pipeline5.GetService(typeof(LoggingRealtimeClient))); + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task SendAsync_SessionUpdateMessage_LogsInvocationAndCompletion(LogLevel level) + { + var collector = new FakeLogCollector(); + + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + await using var innerSession = new TestRealtimeClientSession(); + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseLogging() + .Build(services); + await using var session = await client.CreateSessionAsync(); + + await session.SendAsync(new SessionUpdateRealtimeClientMessage(new RealtimeSessionOptions { Model = "test-model", Instructions = "Be helpful" })); + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + Assert.Collection(logs, + entry => Assert.Contains("SendAsync invoked:", entry.Message), + entry => Assert.Contains("SendAsync completed.", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.Contains("SendAsync invoked.", entry.Message), + entry => Assert.Contains("SendAsync completed.", entry.Message)); + } + else + { + Assert.Empty(logs); + } + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task SendAsync_LogsInvocationAndCompletion(LogLevel level) + { + var collector = new FakeLogCollector(); + + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + await using var innerSession = new TestRealtimeClientSession + { + SendAsyncCallback = (message, cancellationToken) => Task.CompletedTask, + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseLogging() + .Build(services); + await using var session = await client.CreateSessionAsync(); + + await session.SendAsync(new RealtimeClientMessage { MessageId = "test-event-123" }); + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + Assert.Collection(logs, + entry => Assert.Contains("SendAsync invoked:", entry.Message), + entry => Assert.Contains("SendAsync completed.", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.Contains("SendAsync invoked.", entry.Message), + entry => Assert.Contains("SendAsync completed.", entry.Message)); + } + else + { + Assert.Empty(logs); + } + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task GetStreamingResponseAsync_LogsMessagesReceived(LogLevel level) + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + + await using var innerSession = new TestRealtimeClientSession + { + GetStreamingResponseAsyncCallback = (cancellationToken) => GetMessagesAsync() + }; + + static async IAsyncEnumerable GetMessagesAsync() + { + await Task.Yield(); + yield return new RealtimeServerMessage { Type = RealtimeServerMessageType.OutputTextDelta, MessageId = "event-1" }; + yield return new RealtimeServerMessage { Type = RealtimeServerMessageType.OutputAudioDelta, MessageId = "event-2" }; + } + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + await using var session = await client.CreateSessionAsync(); + + await foreach (var message in session.GetStreamingResponseAsync()) + { + // nop + } + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + Assert.Collection(logs, + entry => Assert.Contains("GetStreamingResponseAsync invoked.", entry.Message), + entry => Assert.Contains("received server message:", entry.Message), + entry => Assert.Contains("received server message:", entry.Message), + entry => Assert.Contains("GetStreamingResponseAsync completed.", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.Contains("GetStreamingResponseAsync invoked.", entry.Message), + entry => Assert.Contains("received server message.", entry.Message), + entry => Assert.Contains("received server message.", entry.Message), + entry => Assert.Contains("GetStreamingResponseAsync completed.", entry.Message)); + } + else + { + Assert.Empty(logs); + } + } + + [Fact] + public async Task SendAsync_SessionUpdateMessage_LogsCancellation() + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(LogLevel.Debug)); + + using var cts = new CancellationTokenSource(); + + await using var innerSession = new TestRealtimeClientSession + { + SendAsyncCallback = (msg, cancellationToken) => + { + throw new OperationCanceledException(cancellationToken); + }, + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + await using var session = await client.CreateSessionAsync(); + + cts.Cancel(); + await Assert.ThrowsAsync(() => session.SendAsync(new SessionUpdateRealtimeClientMessage(new RealtimeSessionOptions()), cts.Token)); + + var logs = collector.GetSnapshot(); + Assert.Collection(logs, + entry => Assert.Contains("SendAsync invoked.", entry.Message), + entry => Assert.Contains("SendAsync canceled.", entry.Message)); + } + + [Fact] + public async Task SendAsync_SessionUpdateMessage_LogsErrors() + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(LogLevel.Debug)); + + await using var innerSession = new TestRealtimeClientSession + { + SendAsyncCallback = (msg, cancellationToken) => + { + throw new InvalidOperationException("Test error"); + }, + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + await using var session = await client.CreateSessionAsync(); + + await Assert.ThrowsAsync(() => session.SendAsync(new SessionUpdateRealtimeClientMessage(new RealtimeSessionOptions()))); + + var logs = collector.GetSnapshot(); + Assert.Collection(logs, + entry => Assert.Contains("SendAsync invoked.", entry.Message), + entry => Assert.True(entry.Message.Contains("SendAsync failed.") && entry.Level == LogLevel.Error)); + } + + [Fact] + public async Task GetStreamingResponseAsync_LogsCancellation() + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(LogLevel.Debug)); + + using var cts = new CancellationTokenSource(); + + await using var innerSession = new TestRealtimeClientSession + { + GetStreamingResponseAsyncCallback = (cancellationToken) => ThrowCancellationAsync(cancellationToken) + }; + + static async IAsyncEnumerable ThrowCancellationAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + throw new OperationCanceledException(cancellationToken); +#pragma warning disable CS0162 // Unreachable code detected + yield break; +#pragma warning restore CS0162 + } + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + await using var session = await client.CreateSessionAsync(); + + cts.Cancel(); + await Assert.ThrowsAsync(async () => + { + await foreach (var message in session.GetStreamingResponseAsync(cts.Token)) + { + // nop + } + }); + + var logs = collector.GetSnapshot(); + Assert.Collection(logs, + entry => Assert.Contains("GetStreamingResponseAsync invoked.", entry.Message), + entry => Assert.Contains("GetStreamingResponseAsync canceled.", entry.Message)); + } + + [Fact] + public async Task GetStreamingResponseAsync_LogsErrors() + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(LogLevel.Debug)); + + await using var innerSession = new TestRealtimeClientSession + { + GetStreamingResponseAsyncCallback = (cancellationToken) => ThrowErrorAsync() + }; + + static async IAsyncEnumerable ThrowErrorAsync() + { + await Task.Yield(); + throw new InvalidOperationException("Test error"); +#pragma warning disable CS0162 // Unreachable code detected + yield break; +#pragma warning restore CS0162 + } + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + await using var session = await client.CreateSessionAsync(); + + await Assert.ThrowsAsync(async () => + { + await foreach (var message in session.GetStreamingResponseAsync()) + { + // nop + } + }); + + var logs = collector.GetSnapshot(); + Assert.Collection(logs, + entry => Assert.Contains("GetStreamingResponseAsync invoked.", entry.Message), + entry => Assert.True(entry.Message.Contains("GetStreamingResponseAsync failed.") && entry.Level == LogLevel.Error)); + } + + [Fact] + public async Task GetService_ReturnsLoggingClientWhenRequested() + { + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddFakeLogging()); + + await using var innerSession = new TestRealtimeClientSession(); + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + await using var session = await client.CreateSessionAsync(); + + Assert.NotNull(client.GetService(typeof(LoggingRealtimeClient))); + Assert.Same(session, session.GetService(typeof(IRealtimeClientSession))); + } + + [Fact] + public async Task SendAsync_LogsCancellation() + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(LogLevel.Debug)); + + using var cts = new CancellationTokenSource(); + + await using var innerSession = new TestRealtimeClientSession + { + SendAsyncCallback = (message, cancellationToken) => + { + throw new OperationCanceledException(cancellationToken); + }, + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + await using var session = await client.CreateSessionAsync(); + + cts.Cancel(); + await Assert.ThrowsAsync(() => + session.SendAsync(new RealtimeClientMessage { MessageId = "evt_cancel" }, cts.Token)); + + var logs = collector.GetSnapshot(); + Assert.Collection(logs, + entry => Assert.Contains("SendAsync invoked.", entry.Message), + entry => Assert.Contains("SendAsync canceled.", entry.Message)); + } + + [Fact] + public async Task SendAsync_LogsErrors() + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(LogLevel.Debug)); + + await using var innerSession = new TestRealtimeClientSession + { + SendAsyncCallback = (message, cancellationToken) => + { + throw new InvalidOperationException("Inject error"); + }, + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + await using var session = await client.CreateSessionAsync(); + + await Assert.ThrowsAsync(() => + session.SendAsync(new RealtimeClientMessage())); + + var logs = collector.GetSnapshot(); + Assert.Collection(logs, + entry => Assert.Contains("SendAsync invoked.", entry.Message), + entry => Assert.True(entry.Message.Contains("SendAsync failed.") && entry.Level == LogLevel.Error)); + } + + [Fact] + public async Task JsonSerializerOptions_NullValue_Throws() + { + await using var innerSession = new TestRealtimeClientSession(); + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new LoggingRealtimeClient(innerClient, NullLogger.Instance); + + Assert.Throws("value", () => client.JsonSerializerOptions = null!); + } + + [Fact] + public async Task JsonSerializerOptions_Roundtrip() + { + await using var innerSession = new TestRealtimeClientSession(); + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new LoggingRealtimeClient(innerClient, NullLogger.Instance); + + var customOptions = new System.Text.Json.JsonSerializerOptions(); + client.JsonSerializerOptions = customOptions; + + Assert.Same(customOptions, client.JsonSerializerOptions); + } + + [Fact] + public void UseLogging_NullBuilder_Throws() + { + Assert.Throws("builder", () => + ((RealtimeClientBuilder)null!).UseLogging()); + } + + private sealed class TestRealtimeClient : IRealtimeClient + { + private readonly IRealtimeClientSession _session; + + public TestRealtimeClient(IRealtimeClientSession session) + { + _session = session; + } + + public Task CreateSessionAsync(RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(_session); + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : _session.GetService(serviceType, serviceKey); + + public void Dispose() + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/OpenTelemetryRealtimeClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/OpenTelemetryRealtimeClientTests.cs new file mode 100644 index 00000000000..6537af4d29a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/OpenTelemetryRealtimeClientTests.cs @@ -0,0 +1,1112 @@ +// 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; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using OpenTelemetry.Trace; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenTelemetryRealtimeClientTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExpectedInformationLogged_GetStreamingResponseAsync(bool enableSensitiveData) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions + { + Model = "test-model", + Voice = "alloy", + MaxOutputTokens = 500, + OutputModalities = ["text", "audio"], + Instructions = "Be helpful and friendly.", + SessionKind = RealtimeSessionKind.Conversation, + Tools = [AIFunctionFactory.Create((string query) => query, "Search", "Search for information.")], + }, + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("testprovider", new Uri("http://localhost:12345/realtime"), "gpt-4-realtime") : + null, + GetStreamingResponseAsyncCallback = (cancellationToken) => CallbackAsync(cancellationToken), + }; + + static async IAsyncEnumerable CallbackAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + _ = cancellationToken; + + yield return new RealtimeServerMessage { Type = RealtimeServerMessageType.ResponseCreated, MessageId = "evt_001" }; + yield return new OutputTextAudioRealtimeServerMessage(RealtimeServerMessageType.OutputTextDelta) { OutputIndex = 0, Text = "Hello" }; + yield return new OutputTextAudioRealtimeServerMessage(RealtimeServerMessageType.OutputTextDelta) { OutputIndex = 0, Text = " there!" }; + yield return new OutputTextAudioRealtimeServerMessage(RealtimeServerMessageType.OutputTextDone) { OutputIndex = 0, Text = "Hello there!" }; + + yield return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone) + { + ResponseId = "resp_12345", + Status = "completed", + Usage = new UsageDetails + { + InputTokenCount = 15, + OutputTokenCount = 25, + TotalTokenCount = 40, + CachedInputTokenCount = 3, + InputAudioTokenCount = 10, + InputTextTokenCount = 5, + OutputAudioTokenCount = 18, + OutputTextTokenCount = 7, + }, + }; + } + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = enableSensitiveData; + client.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume responses + } + + // When sensitive data is enabled, we get one activity per message with content plus one for output/response + // GetClientMessagesAsync yields 3 messages but only 2 have content, so 2 input activities + 1 output activity = 3 activities + // When sensitive data is disabled, we get only one activity for the response + Activity activity; + if (enableSensitiveData) + { + Assert.Equal(3, activities.Count); + + // The last activity is the response/output activity with ResponseDone data + activity = activities[2]; + } + else + { + activity = Assert.Single(activities); + } + + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + + Assert.Equal("localhost", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); + + Assert.Equal("realtime test-model", activity.DisplayName); + Assert.Equal("testprovider", activity.GetTagItem("gen_ai.provider.name")); + Assert.Equal("chat", activity.GetTagItem("gen_ai.operation.name")); + + Assert.Equal("test-model", activity.GetTagItem("gen_ai.request.model")); + Assert.Equal(500, activity.GetTagItem("gen_ai.request.max_tokens")); + + // Realtime-specific attributes + Assert.Equal("conversation", activity.GetTagItem("gen_ai.realtime.session_kind")); + Assert.Equal("alloy", activity.GetTagItem("gen_ai.realtime.voice")); + Assert.Equal("""["text", "audio"]""", activity.GetTagItem("gen_ai.realtime.output_modalities")); + + // Response attributes + Assert.Equal("resp_12345", activity.GetTagItem("gen_ai.response.id")); + Assert.Equal("""["completed"]""", activity.GetTagItem("gen_ai.response.finish_reasons")); + Assert.Equal(15, activity.GetTagItem("gen_ai.usage.input_tokens")); + Assert.Equal(25, activity.GetTagItem("gen_ai.usage.output_tokens")); + Assert.Equal(3, activity.GetTagItem("gen_ai.usage.cache_read.input_tokens")); + Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_audio_tokens")); + Assert.Equal(5, activity.GetTagItem("gen_ai.usage.input_text_tokens")); + Assert.Equal(18, activity.GetTagItem("gen_ai.usage.output_audio_tokens")); + Assert.Equal(7, activity.GetTagItem("gen_ai.usage.output_text_tokens")); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + + var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + if (enableSensitiveData) + { + Assert.Equal(ReplaceWhitespace(""" + [ + { + "type": "text", + "content": "Be helpful and friendly." + } + ] + """), ReplaceWhitespace(tags["gen_ai.system_instructions"])); + + Assert.Equal(ReplaceWhitespace(""" + [ + { + "type": "function", + "name": "Search", + "description": "Search for information.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": [ + "query" + ] + } + } + ] + """), ReplaceWhitespace(tags["gen_ai.tool.definitions"])); + } + else + { + Assert.False(tags.ContainsKey("gen_ai.system_instructions")); + Assert.False(tags.ContainsKey("gen_ai.tool.definitions")); + } + } + + [Fact] + public async Task GetStreamingResponseAsync_TracesError() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetStreamingResponseAsyncCallback = (cancellationToken) => ThrowingCallbackAsync(cancellationToken), + }; + + static async IAsyncEnumerable ThrowingCallbackAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + _ = cancellationToken; + yield return new RealtimeServerMessage { Type = RealtimeServerMessageType.ResponseCreated }; + throw new InvalidOperationException("Streaming error"); + } + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseOpenTelemetry(loggerFactory, sourceName) + .Build(); + await using var session = await client.CreateSessionAsync(); + + await Assert.ThrowsAsync(async () => + { + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume responses + } + }); + + var activity = Assert.Single(activities); + Assert.Equal("System.InvalidOperationException", activity.GetTagItem("error.type")); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + Assert.Equal("Streaming error", activity.StatusDescription); + + // Exception is logged via ILogger + var logEntry = Assert.Single(collector.GetSnapshot()); + Assert.Equal("gen_ai.client.operation.exception", logEntry.Id.Name); + Assert.Equal(LogLevel.Warning, logEntry.Level); + Assert.IsType(logEntry.Exception); + Assert.Equal("Streaming error", logEntry.Exception!.Message); + } + + [Fact] + public async Task GetStreamingResponseAsync_TracesErrorFromResponse() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetStreamingResponseAsyncCallback = (cancellationToken) => ErrorResponseCallbackAsync(cancellationToken), + }; + + static async IAsyncEnumerable ErrorResponseCallbackAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + _ = cancellationToken; + + yield return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone) + { + ResponseId = "resp_error", + Status = "failed", + Error = new ErrorContent("Something went wrong") { ErrorCode = "internal_error" }, + }; + } + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseOpenTelemetry(sourceName: sourceName) + .Build(); + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume responses + } + + var activity = Assert.Single(activities); + Assert.Equal("internal_error", activity.GetTagItem("error.type")); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + Assert.Equal("Something went wrong", activity.StatusDescription); + } + + [Fact] + public async Task NoListeners_NoActivityCreated() + { + // Create a tracer provider but don't add a source for our session + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("different-source") + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetStreamingResponseAsyncCallback = (cancellationToken) => EmptyCallbackAsync(cancellationToken), + }; + +#pragma warning disable S4144 // Methods should not have identical implementations + static async IAsyncEnumerable EmptyCallbackAsync([EnumeratorCancellation] CancellationToken cancellationToken) +#pragma warning restore S4144 + { + await Task.Yield(); + _ = cancellationToken; + + yield break; + } + + var sourceName = Guid.NewGuid().ToString(); + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseOpenTelemetry(sourceName: sourceName) + .Build(); + await using var session = await client.CreateSessionAsync(); + + // This should work without errors even without listeners + var count = 0; + await foreach (var response in session.GetStreamingResponseAsync()) + { + count++; + } + + // Verify the session worked correctly without listeners + Assert.True(count >= 0); + } + + [Fact] + public async Task InvalidArgs_Throws() + { + await using var innerSession = new TestRealtimeClientSession(); + using var innerClient = new TestRealtimeClient(innerSession); + + Assert.Throws("innerClient", () => new OpenTelemetryRealtimeClient(null!)); + using var client = new OpenTelemetryRealtimeClient(innerClient); + Assert.Throws("value", () => client.JsonSerializerOptions = null!); + } + + [Fact] + public void SessionUpdateMessage_NullOptions_Throws() + { + Assert.Throws("options", () => new SessionUpdateRealtimeClientMessage(null!)); + } + + [Fact] + public async Task GetService_ReturnsActivitySource() + { + await using var innerSession = new TestRealtimeClientSession(); + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient); + await using var session = await client.CreateSessionAsync(); + + var activitySource = session.GetService(typeof(ActivitySource)); + Assert.NotNull(activitySource); + Assert.IsType(activitySource); + } + + [Fact] + public async Task GetService_ReturnsSelf() + { + await using var innerSession = new TestRealtimeClientSession(); + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient); + + Assert.Same(client, client.GetService(typeof(OpenTelemetryRealtimeClient))); + + await using var session = await client.CreateSessionAsync(); + Assert.Same(session, session.GetService(typeof(IRealtimeClientSession))); + } + + [Fact] + public async Task TranscriptionSessionKind_Logged() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions + { + Model = "whisper-1", + SessionKind = RealtimeSessionKind.Transcription, + }, + GetStreamingResponseAsyncCallback = (cancellationToken) => TranscriptionCallbackAsync(cancellationToken), + }; + + static async IAsyncEnumerable TranscriptionCallbackAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + _ = cancellationToken; + + yield return new InputAudioTranscriptionRealtimeServerMessage(RealtimeServerMessageType.InputAudioTranscriptionCompleted) + { + Transcription = "Hello world", + }; + yield return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone); + } + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = innerClient + .AsBuilder() + .UseOpenTelemetry(sourceName: sourceName) + .Build(); + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + var activity = Assert.Single(activities); + Assert.Equal("transcription", activity.GetTagItem("gen_ai.realtime.session_kind")); + } + + [Fact] + public async Task ToolCallContentInClientMessages_LoggedAsInputMessages() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => SimpleCallbackAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesWithToolResultAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // With sensitive data enabled, we get one activity per message with content plus one for output + // GetClientMessagesWithToolResultAsync yields 2 messages but only 1 has content, so 1 input activity + 1 output = 2 activities + Assert.Equal(2, activities.Count); + var inputActivity = activities[0]; + var inputMessages = inputActivity.GetTagItem("gen_ai.input.messages")?.ToString(); + Assert.NotNull(inputMessages); + Assert.Contains("tool_call_response", inputMessages); + Assert.Contains("call_1", inputMessages); + } + + [Fact] + public async Task ToolCallContentInServerMessages_LoggedAsOutputMessages() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => CallbackWithToolCallAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // With sensitive data enabled, we get one activity per message with content plus one for output + // GetClientMessagesAsync yields 3 messages but only 2 have content, so 2 input activities + 1 output = 3 activities + Assert.Equal(3, activities.Count); + var outputActivity = activities[2]; + var outputMessages = outputActivity.GetTagItem("gen_ai.output.messages")?.ToString(); + Assert.NotNull(outputMessages); + Assert.Contains("tool_call", outputMessages); + Assert.Contains("search", outputMessages); + } + + [Fact] + public async Task ToolContentNotLoggedWithoutSensitiveData() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => CallbackWithToolCallAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = false; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesWithToolResultAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + var activity = Assert.Single(activities); + Assert.Null(activity.GetTagItem("gen_ai.input.messages")); + Assert.Null(activity.GetTagItem("gen_ai.output.messages")); + } + +#pragma warning disable S4144 // Methods should not have identical implementations + private static async IAsyncEnumerable SimpleCallbackAsync([EnumeratorCancellation] CancellationToken cancellationToken) +#pragma warning restore S4144 + { + await Task.Yield(); + _ = cancellationToken; + + yield return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone); + } + +#pragma warning disable IDE0060 // Remove unused parameter + private static async IAsyncEnumerable GetClientMessagesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) +#pragma warning restore IDE0060 + { + await Task.Yield(); + yield return new InputAudioBufferAppendRealtimeClientMessage(new DataContent(new byte[] { 1, 2, 3 }, "audio/pcm")); + yield return new InputAudioBufferCommitRealtimeClientMessage(); + yield return new CreateResponseRealtimeClientMessage(); + } + +#pragma warning disable IDE0060 // Remove unused parameter + private static async IAsyncEnumerable GetClientMessagesWithToolResultAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) +#pragma warning restore IDE0060 + { + await Task.Yield(); + var contentItem = new RealtimeConversationItem([new FunctionResultContent("call_1", "result_value")], role: ChatRole.Tool); + yield return new CreateConversationItemRealtimeClientMessage(contentItem); + yield return new CreateResponseRealtimeClientMessage(); + } + + private static async IAsyncEnumerable CallbackWithToolCallAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + _ = cancellationToken; + + // Yield a function call item from the server using ResponseOutputItemRealtimeServerMessage + var contentItem = new RealtimeConversationItem( + [new FunctionCallContent("call_123", "search", new Dictionary { ["query"] = "test" })], + role: ChatRole.Assistant); + yield return new ResponseOutputItemRealtimeServerMessage(RealtimeServerMessageType.ResponseOutputItemDone) + { + Item = contentItem, + }; + + yield return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone); + } + + [Fact] + public async Task AudioBufferAppendMessage_LoggedAsInputMessage() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => SimpleCallbackAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // With sensitive data enabled, we get one activity per message with content plus one for output + // GetClientMessagesAsync yields 3 messages but only 2 have content, so 2 input activities + 1 output = 3 activities + Assert.Equal(3, activities.Count); + var inputActivity = activities[0]; + var inputMessages = inputActivity.GetTagItem("gen_ai.input.messages")?.ToString(); + Assert.NotNull(inputMessages); + Assert.Contains("blob", inputMessages); + Assert.Contains("audio", inputMessages); + } + + [Fact] + public async Task AudioBufferCommitMessage_LoggedAsInputMessage() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => SimpleCallbackAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // With sensitive data enabled, we get one activity per message with content plus one for output + // GetClientMessagesAsync yields 3 messages but only 2 have content, so 2 input activities + 1 output = 3 activities + // The audio_commit message is the 2nd message with content, so it appears in activities[1] + Assert.Equal(3, activities.Count); + var inputActivity = activities[1]; + var inputMessages = inputActivity.GetTagItem("gen_ai.input.messages")?.ToString(); + Assert.NotNull(inputMessages); + Assert.Contains("audio_commit", inputMessages); + } + + [Fact] + public async Task ResponseCreateMessageWithInstructions_LoggedAsInputMessage() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => SimpleCallbackAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesWithInstructionsAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // With sensitive data enabled, we get 2 activities: input (first) and output (second) + Assert.Equal(2, activities.Count); + var inputActivity = activities[0]; + var inputMessages = inputActivity.GetTagItem("gen_ai.input.messages")?.ToString(); + Assert.NotNull(inputMessages); + Assert.Contains("instructions", inputMessages); + Assert.Contains("Be very helpful", inputMessages); + } + + [Fact] + public async Task ResponseCreateMessageWithItems_LoggedAsInputMessage() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => SimpleCallbackAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesWithItemsAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // With sensitive data enabled, we get 2 activities: input (first) and output (second) + Assert.Equal(2, activities.Count); + var inputActivity = activities[0]; + var inputMessages = inputActivity.GetTagItem("gen_ai.input.messages")?.ToString(); + Assert.NotNull(inputMessages); + Assert.Contains("text", inputMessages); + Assert.Contains("Hello from client", inputMessages); + } + + [Fact] + public async Task OutputTextAudioMessage_LoggedAsOutputMessage() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => CallbackWithTextOutputAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // GetClientMessagesAsync yields 3 messages but only 2 have content, so 2 input activities + 1 output = 3 activities + Assert.Equal(3, activities.Count); + var outputMessages = activities[2].GetTagItem("gen_ai.output.messages")?.ToString(); + Assert.NotNull(outputMessages); + Assert.Contains("assistant", outputMessages); + Assert.Contains("Hello from server", outputMessages); + } + + [Fact] + public async Task InputAudioTranscriptionMessage_LoggedAsOutputMessage() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => CallbackWithTranscriptionAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // GetClientMessagesAsync yields 3 messages but only 2 have content, so 2 input activities + 1 output = 3 activities + Assert.Equal(3, activities.Count); + var outputMessages = activities[2].GetTagItem("gen_ai.output.messages")?.ToString(); + Assert.NotNull(outputMessages); + Assert.Contains("input_transcription", outputMessages); + Assert.Contains("Transcribed audio content", outputMessages); + } + + [Fact] + public async Task ServerErrorMessage_LoggedAsOutputMessage() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => CallbackWithServerErrorAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // GetClientMessagesAsync yields 3 messages but only 2 have content, so 2 input activities + 1 output = 3 activities + Assert.Equal(3, activities.Count); + var outputMessages = activities[2].GetTagItem("gen_ai.output.messages")?.ToString(); + Assert.NotNull(outputMessages); + Assert.Contains("error", outputMessages); + Assert.Contains("Something went wrong on server", outputMessages); + } + + [Fact] + public async Task ConversationItemCreateWithTextContent_LoggedAsInputMessage() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => SimpleCallbackAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesWithTextContentAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // GetClientMessagesWithTextContentAsync yields 2 messages but only 1 has content, so 1 input activity + 1 output = 2 activities + Assert.Equal(2, activities.Count); + var inputMessages = activities[0].GetTagItem("gen_ai.input.messages")?.ToString(); + Assert.NotNull(inputMessages); + Assert.Contains("user", inputMessages); + Assert.Contains("User text message", inputMessages); + } + + [Fact] + public async Task DataContentInClientMessage_LoggedWithModality() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + await using var innerSession = new TestRealtimeClientSession + { + Options = new RealtimeSessionOptions { Model = "test-model" }, + GetServiceCallback = (serviceType, _) => + serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("test-provider") : null, + GetStreamingResponseAsyncCallback = (cancellationToken) => SimpleCallbackAsync(cancellationToken), + }; + + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient, sourceName: sourceName); + client.EnableSensitiveData = true; + await using var session = await client.CreateSessionAsync(); + + await foreach (var msg in GetClientMessagesWithImageContentAsync()) + { + await session.SendAsync(msg); + } + + await foreach (var response in session.GetStreamingResponseAsync()) + { + // Consume + } + + // GetClientMessagesWithImageContentAsync yields 2 messages but only 1 has content, so 1 input activity + 1 output = 2 activities + Assert.Equal(2, activities.Count); + var inputMessages = activities[0].GetTagItem("gen_ai.input.messages")?.ToString(); + Assert.NotNull(inputMessages); + Assert.Contains("blob", inputMessages); + Assert.Contains("image", inputMessages); + Assert.Contains("image/png", inputMessages); + } + +#pragma warning disable IDE0060 // Remove unused parameter + private static async IAsyncEnumerable GetClientMessagesWithInstructionsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) +#pragma warning restore IDE0060 + { + await Task.Yield(); + yield return new CreateResponseRealtimeClientMessage { Instructions = "Be very helpful" }; + } + +#pragma warning disable IDE0060 // Remove unused parameter + private static async IAsyncEnumerable GetClientMessagesWithItemsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) +#pragma warning restore IDE0060 + { + await Task.Yield(); + var item = new RealtimeConversationItem([new TextContent("Hello from client")], role: ChatRole.User); + yield return new CreateResponseRealtimeClientMessage { Items = [item] }; + } + +#pragma warning disable IDE0060 // Remove unused parameter + private static async IAsyncEnumerable GetClientMessagesWithTextContentAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) +#pragma warning restore IDE0060 + { + await Task.Yield(); + var item = new RealtimeConversationItem([new TextContent("User text message")], role: ChatRole.User); + yield return new CreateConversationItemRealtimeClientMessage(item); + yield return new CreateResponseRealtimeClientMessage(); + } + +#pragma warning disable IDE0060 // Remove unused parameter + private static async IAsyncEnumerable GetClientMessagesWithImageContentAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) +#pragma warning restore IDE0060 + { + await Task.Yield(); + var imageData = new DataContent(new byte[] { 0x89, 0x50, 0x4E, 0x47 }, "image/png"); + var item = new RealtimeConversationItem([imageData], role: ChatRole.User); + yield return new CreateConversationItemRealtimeClientMessage(item); + yield return new CreateResponseRealtimeClientMessage(); + } + + private static async IAsyncEnumerable CallbackWithTextOutputAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + _ = cancellationToken; + + yield return new OutputTextAudioRealtimeServerMessage(RealtimeServerMessageType.OutputTextDone) + { + Text = "Hello from server", + }; + yield return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone); + } + + private static async IAsyncEnumerable CallbackWithTranscriptionAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + _ = cancellationToken; + + yield return new InputAudioTranscriptionRealtimeServerMessage(RealtimeServerMessageType.InputAudioTranscriptionCompleted) + { + Transcription = "Transcribed audio content", + }; + yield return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone); + } + + private static async IAsyncEnumerable CallbackWithServerErrorAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + _ = cancellationToken; + + yield return new ErrorRealtimeServerMessage + { + Error = new ErrorContent("Something went wrong on server"), + }; + yield return new ResponseCreatedRealtimeServerMessage(RealtimeServerMessageType.ResponseDone); + } + + private static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", " ").Trim(); + + [Fact] + public void UseOpenTelemetry_NullBuilder_Throws() + { + Assert.Throws("builder", () => + ((RealtimeClientBuilder)null!).UseOpenTelemetry()); + } + + [Fact] + public async Task UseOpenTelemetry_BuildsPipeline() + { + await using var innerSession = new TestRealtimeClientSession(); + using var innerClient = new TestRealtimeClient(innerSession); + var builder = new RealtimeClientBuilder(innerClient); + + builder.UseOpenTelemetry(); + + using var pipeline = builder.Build(); + await using var session = await pipeline.CreateSessionAsync(); + + var otelClient = pipeline.GetService(typeof(OpenTelemetryRealtimeClient)); + Assert.NotNull(otelClient); + + var typedClient = Assert.IsType(otelClient); + typedClient.EnableSensitiveData = true; + Assert.True(typedClient.EnableSensitiveData); + } + + [Fact] + public async Task DisposeAsync_CanBeCalledMultipleTimes() + { + await using var innerSession = new TestRealtimeClientSession(); + using var innerClient = new TestRealtimeClient(innerSession); + using var client = new OpenTelemetryRealtimeClient(innerClient); + var session = await client.CreateSessionAsync(); + + await session.DisposeAsync(); + await session.DisposeAsync(); + + // Verifying no exception is thrown on double dispose + Assert.NotNull(session); + } + + private sealed class TestRealtimeClient : IRealtimeClient + { + private readonly IRealtimeClientSession _session; + + public TestRealtimeClient(IRealtimeClientSession session) + { + _session = session; + } + + public Task CreateSessionAsync(RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(_session); + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : _session.GetService(serviceType, serviceKey); + + public void Dispose() + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientBuilderTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientBuilderTests.cs new file mode 100644 index 00000000000..5b4b7b8b7f4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientBuilderTests.cs @@ -0,0 +1,182 @@ +// 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; +using System.Threading.Tasks; +using Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + +namespace Microsoft.Extensions.AI; + +public class RealtimeClientBuilderTests +{ + [Fact] + public void Ctor_NullClient_Throws() + { + Assert.Throws("innerClient", () => new RealtimeClientBuilder((IRealtimeClient)null!)); + } + + [Fact] + public void Ctor_NullFactory_Throws() + { + Assert.Throws("innerClientFactory", () => new RealtimeClientBuilder((Func)null!)); + } + + [Fact] + public void Build_WithNoMiddleware_ReturnsInnerClient() + { + using var inner = new TestRealtimeClient(); + var builder = new RealtimeClientBuilder(inner); + + var result = builder.Build(); + Assert.Same(inner, result); + } + + [Fact] + public void Build_WithFactory_UsesFactory() + { + using var inner = new TestRealtimeClient(); + var builder = new RealtimeClientBuilder(_ => inner); + + var result = builder.Build(); + Assert.Same(inner, result); + } + + [Fact] + public void Use_NullClientFactory_Throws() + { + using var inner = new TestRealtimeClient(); + var builder = new RealtimeClientBuilder(inner); + + Assert.Throws("clientFactory", () => builder.Use((Func)null!)); + Assert.Throws("clientFactory", () => builder.Use((Func)null!)); + } + + [Fact] + public void Build_PipelineOrder_FirstAddedIsOutermost() + { + var callOrder = new List(); + using var inner = new TestRealtimeClient(); + + var builder = new RealtimeClientBuilder(inner); + builder.Use(client => new OrderTrackingClient(client, "first", callOrder)); + builder.Use(client => new OrderTrackingClient(client, "second", callOrder)); + + using var pipeline = builder.Build(); + + // The outermost should be "first" (added first) + var outermost = Assert.IsType(pipeline); + Assert.Equal("first", outermost.Name); + + var middle = Assert.IsType(outermost.GetInner()); + Assert.Equal("second", middle.Name); + + Assert.Same(inner, middle.GetInner()); + } + + [Fact] + public void Build_WithServiceProvider_PassesToFactory() + { + IServiceProvider? capturedServices = null; + using var inner = new TestRealtimeClient(); + + var builder = new RealtimeClientBuilder(inner); + builder.Use((client, services) => + { + capturedServices = services; + return client; + }); + + var services = new EmptyServiceProvider(); + builder.Build(services); + + Assert.Same(services, capturedServices); + } + + [Fact] + public void Build_NullServiceProvider_UsesEmptyProvider() + { + IServiceProvider? capturedServices = null; + using var inner = new TestRealtimeClient(); + + var builder = new RealtimeClientBuilder(inner); + builder.Use((client, services) => + { + capturedServices = services; + return client; + }); + + builder.Build(null); + + Assert.NotNull(capturedServices); + } + + [Fact] + public void Use_ReturnsSameBuilder_ForChaining() + { + using var inner = new TestRealtimeClient(); + var builder = new RealtimeClientBuilder(inner); + + var returned = builder.Use(c => c); + Assert.Same(builder, returned); + } + + [Fact] + public void AsBuilder_NullClient_Throws() + { + Assert.Throws("innerClient", () => ((IRealtimeClient)null!).AsBuilder()); + } + + [Fact] + public void AsBuilder_ReturnsBuilder() + { + using var inner = new TestRealtimeClient(); + var builder = inner.AsBuilder(); + + Assert.NotNull(builder); + Assert.Same(inner, builder.Build()); + } + + private sealed class TestRealtimeClient : IRealtimeClient + { + public Task CreateSessionAsync(RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new TestRealtimeClientSession()); + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public void Dispose() + { + } + } + + private sealed class OrderTrackingClient : DelegatingRealtimeClient + { + public string Name { get; } + private readonly List _callOrder; + + public OrderTrackingClient(IRealtimeClient inner, string name, List callOrder) + : base(inner) + { + Name = name; + _callOrder = callOrder; + } + + public IRealtimeClient GetInner() => InnerClient; + + public override async Task CreateSessionAsync( + RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default) + { + _callOrder.Add(Name); + return await base.CreateSessionAsync(options, cancellationToken); + } + } + + private sealed class EmptyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientExtensionsTests.cs new file mode 100644 index 00000000000..aed335164f0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientExtensionsTests.cs @@ -0,0 +1,124 @@ +// 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.Threading; +using System.Threading.Tasks; +using Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + +namespace Microsoft.Extensions.AI; + +public class RealtimeClientExtensionsTests +{ + [Fact] + public void GetService_NullClient_Throws() + { + Assert.Throws("client", () => ((IRealtimeClient)null!).GetService()); + } + + [Fact] + public void GetService_ReturnsMatchingService() + { + using var client = new TestRealtimeClient(); + var result = client.GetService(); + Assert.Same(client, result); + } + + [Fact] + public void GetService_ReturnsNullForNonMatchingType() + { + using var client = new TestRealtimeClient(); + var result = client.GetService(); + Assert.Null(result); + } + + [Fact] + public void GetService_WithServiceKey_ReturnsNull() + { + using var client = new TestRealtimeClient(); + var result = client.GetService("someKey"); + Assert.Null(result); + } + + [Fact] + public void GetService_ReturnsInterfaceType() + { + using var client = new TestRealtimeClient(); + var result = client.GetService(); + Assert.Same(client, result); + } + + [Fact] + public void GetRequiredService_NullClient_Throws() + { + Assert.Throws("client", () => ((IRealtimeClient)null!).GetRequiredService(typeof(string))); + Assert.Throws("client", () => ((IRealtimeClient)null!).GetRequiredService()); + } + + [Fact] + public void GetRequiredService_NullServiceType_Throws() + { + using var client = new TestRealtimeClient(); + Assert.Throws("serviceType", () => client.GetRequiredService(null!)); + } + + [Fact] + public void GetRequiredService_ReturnsMatchingService() + { + using var client = new TestRealtimeClient(); + var result = client.GetRequiredService(); + Assert.Same(client, result); + } + + [Fact] + public void GetRequiredService_ReturnsInterfaceType() + { + using var client = new TestRealtimeClient(); + var result = client.GetRequiredService(); + Assert.Same(client, result); + } + + [Fact] + public void GetRequiredService_NonGeneric_ReturnsMatchingService() + { + using var client = new TestRealtimeClient(); + var result = client.GetRequiredService(typeof(TestRealtimeClient)); + Assert.Same(client, result); + } + + [Fact] + public void GetRequiredService_ThrowsForNonMatchingType() + { + using var client = new TestRealtimeClient(); + Assert.Throws(() => client.GetRequiredService()); + } + + [Fact] + public void GetRequiredService_NonGeneric_ThrowsForNonMatchingType() + { + using var client = new TestRealtimeClient(); + Assert.Throws(() => client.GetRequiredService(typeof(string))); + } + + [Fact] + public void GetRequiredService_WithServiceKey_ThrowsForNonMatchingKey() + { + using var client = new TestRealtimeClient(); + Assert.Throws(() => client.GetRequiredService("someKey")); + } + + private sealed class TestRealtimeClient : IRealtimeClient + { + public Task CreateSessionAsync(RealtimeSessionOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new TestRealtimeClientSession()); + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public void Dispose() + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientSessionExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientSessionExtensionsTests.cs new file mode 100644 index 00000000000..e9ef04f8f8c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Realtime/RealtimeClientSessionExtensionsTests.cs @@ -0,0 +1,110 @@ +// 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.Threading.Tasks; +using Xunit; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + +namespace Microsoft.Extensions.AI; + +public class RealtimeClientSessionExtensionsTests +{ + [Fact] + public void GetService_NullSession_Throws() + { + Assert.Throws("session", () => ((IRealtimeClientSession)null!).GetService()); + } + + [Fact] + public async Task GetService_ReturnsMatchingService() + { + await using var session = new TestRealtimeClientSession(); + var result = session.GetService(); + Assert.Same(session, result); + } + + [Fact] + public async Task GetService_ReturnsNullForNonMatchingType() + { + await using var session = new TestRealtimeClientSession(); + var result = session.GetService(); + Assert.Null(result); + } + + [Fact] + public async Task GetService_WithServiceKey_ReturnsNull() + { + await using var session = new TestRealtimeClientSession(); + var result = session.GetService("someKey"); + Assert.Null(result); + } + + [Fact] + public async Task GetService_ReturnsInterfaceType() + { + await using var session = new TestRealtimeClientSession(); + var result = session.GetService(); + Assert.Same(session, result); + } + + [Fact] + public void GetRequiredService_NullSession_Throws() + { + Assert.Throws("session", () => ((IRealtimeClientSession)null!).GetRequiredService(typeof(string))); + Assert.Throws("session", () => ((IRealtimeClientSession)null!).GetRequiredService()); + } + + [Fact] + public async Task GetRequiredService_NullServiceType_Throws() + { + await using var session = new TestRealtimeClientSession(); + Assert.Throws("serviceType", () => session.GetRequiredService(null!)); + } + + [Fact] + public async Task GetRequiredService_ReturnsMatchingService() + { + await using var session = new TestRealtimeClientSession(); + var result = session.GetRequiredService(); + Assert.Same(session, result); + } + + [Fact] + public async Task GetRequiredService_ReturnsInterfaceType() + { + await using var session = new TestRealtimeClientSession(); + var result = session.GetRequiredService(); + Assert.Same(session, result); + } + + [Fact] + public async Task GetRequiredService_NonGeneric_ReturnsMatchingService() + { + await using var session = new TestRealtimeClientSession(); + var result = session.GetRequiredService(typeof(TestRealtimeClientSession)); + Assert.Same(session, result); + } + + [Fact] + public async Task GetRequiredService_ThrowsForNonMatchingType() + { + await using var session = new TestRealtimeClientSession(); + Assert.Throws(() => session.GetRequiredService()); + } + + [Fact] + public async Task GetRequiredService_NonGeneric_ThrowsForNonMatchingType() + { + await using var session = new TestRealtimeClientSession(); + Assert.Throws(() => session.GetRequiredService(typeof(string))); + } + + [Fact] + public async Task GetRequiredService_WithServiceKey_ThrowsForNonMatchingKey() + { + await using var session = new TestRealtimeClientSession(); + Assert.Throws(() => session.GetRequiredService("someKey")); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/OpenTelemetrySpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/OpenTelemetrySpeechToTextClientTests.cs index c243bf2bf12..06388001aa0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/OpenTelemetrySpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/OpenTelemetrySpeechToTextClientTests.cs @@ -10,6 +10,8 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using OpenTelemetry.Trace; using Xunit; @@ -147,4 +149,64 @@ await client.GetStreamingTextAsync(Stream.Null, options).ToSpeechToTextResponseA static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", " ").Trim(); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExceptionLogged_Async(bool streaming) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + + var expectedException = new InvalidOperationException("test exception message"); + + using var innerClient = new TestSpeechToTextClient + { + GetTextAsyncCallback = (stream, options, cancellationToken) => throw expectedException, + GetStreamingTextAsyncCallback = (stream, options, cancellationToken) => throw expectedException, + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(SpeechToTextClientMetadata) ? new SpeechToTextClientMetadata("testservice", new Uri("http://localhost:12345"), "testmodel") : + null, + }; + + using var client = innerClient + .AsBuilder() + .UseOpenTelemetry(loggerFactory, sourceName) + .Build(); + + if (streaming) + { + await Assert.ThrowsAsync(async () => + { + await foreach (var update in client.GetStreamingTextAsync(Stream.Null)) + { + _ = update; + } + }); + } + else + { + await Assert.ThrowsAsync(() => + client.GetTextAsync(Stream.Null)); + } + + var activity = Assert.Single(activities); + + // Existing error behavior is preserved + Assert.Equal(expectedException.GetType().FullName, activity.GetTagItem("error.type")); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + + // Exception is logged via ILogger + var logEntry = Assert.Single(collector.GetSnapshot()); + Assert.Equal("gen_ai.client.operation.exception", logEntry.Id.Name); + Assert.Equal(LogLevel.Warning, logEntry.Level); + Assert.Same(expectedException, logEntry.Exception); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/ConfigureOptionsTextToSpeechClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/ConfigureOptionsTextToSpeechClientTests.cs new file mode 100644 index 00000000000..285b4ba0ba9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/ConfigureOptionsTextToSpeechClientTests.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ConfigureOptionsTextToSpeechClientTests +{ + [Fact] + public void ConfigureOptionsTextToSpeechClient_InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new ConfigureOptionsTextToSpeechClient(null!, _ => { })); + Assert.Throws("configure", () => new ConfigureOptionsTextToSpeechClient(new TestTextToSpeechClient(), null!)); + } + + [Fact] + public void ConfigureOptions_InvalidArgs_Throws() + { + using var innerClient = new TestTextToSpeechClient(); + var builder = innerClient.AsBuilder(); + Assert.Throws("configure", () => builder.ConfigureOptions(null!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullProvidedOptions) + { + TextToSpeechOptions? providedOptions = nullProvidedOptions ? null : new() { ModelId = "test" }; + TextToSpeechOptions? returnedOptions = null; + TextToSpeechResponse expectedResponse = new([]); + var expectedUpdates = Enumerable.Range(0, 3).Select(i => new TextToSpeechResponseUpdate()).ToArray(); + using CancellationTokenSource cts = new(); + + using ITextToSpeechClient ttsInnerClient = new TestTextToSpeechClient + { + GetAudioAsyncCallback = (text, options, cancellationToken) => + { + Assert.Same(returnedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return Task.FromResult(expectedResponse); + }, + + GetStreamingAudioAsyncCallback = (text, options, cancellationToken) => + { + Assert.Same(returnedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return YieldUpdates(expectedUpdates); + }, + }; + + using var client = ttsInnerClient + .AsBuilder() + .ConfigureOptions(options => + { + Assert.NotSame(providedOptions, options); + if (nullProvidedOptions) + { + Assert.Null(options.ModelId); + } + else + { + Assert.Equal(providedOptions!.ModelId, options.ModelId); + } + + returnedOptions = options; + }) + .Build(); + + var response = await client.GetAudioAsync("Hello, world!", providedOptions, cts.Token); + Assert.Same(expectedResponse, response); + + int i = 0; + await using var e = client.GetStreamingAudioAsync("Hello, world!", providedOptions, cts.Token).GetAsyncEnumerator(); + while (i < expectedUpdates.Length) + { + Assert.True(await e.MoveNextAsync()); + Assert.Same(expectedUpdates[i++], e.Current); + } + + Assert.False(await e.MoveNextAsync()); + + static async IAsyncEnumerable YieldUpdates(TextToSpeechResponseUpdate[] updates) + { + foreach (var update in updates) + { + await Task.Yield(); + yield return update; + } + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/LoggingTextToSpeechClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/LoggingTextToSpeechClientTests.cs new file mode 100644 index 00000000000..f1c2ed4c143 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/LoggingTextToSpeechClientTests.cs @@ -0,0 +1,150 @@ +// 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.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class LoggingTextToSpeechClientTests +{ + [Fact] + public void LoggingTextToSpeechClient_InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new LoggingTextToSpeechClient(null!, NullLogger.Instance)); + Assert.Throws("logger", () => new LoggingTextToSpeechClient(new TestTextToSpeechClient(), null!)); + } + + [Fact] + public void UseLogging_AvoidsInjectingNopClient() + { + using var innerClient = new TestTextToSpeechClient(); + + Assert.Null(innerClient.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(LoggingTextToSpeechClient))); + Assert.Same(innerClient, innerClient.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(ITextToSpeechClient))); + + using var factory = LoggerFactory.Create(b => b.AddFakeLogging()); + Assert.NotNull(innerClient.AsBuilder().UseLogging(factory).Build().GetService(typeof(LoggingTextToSpeechClient))); + + ServiceCollection c = new(); + c.AddFakeLogging(); + var services = c.BuildServiceProvider(); + Assert.NotNull(innerClient.AsBuilder().UseLogging().Build(services).GetService(typeof(LoggingTextToSpeechClient))); + Assert.NotNull(innerClient.AsBuilder().UseLogging(null).Build(services).GetService(typeof(LoggingTextToSpeechClient))); + Assert.Null(innerClient.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build(services).GetService(typeof(LoggingTextToSpeechClient))); + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task GetAudioAsync_LogsResponseInvocationAndCompletion(LogLevel level) + { + var collector = new FakeLogCollector(); + + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + using ITextToSpeechClient innerClient = new TestTextToSpeechClient + { + GetAudioAsyncCallback = (text, options, cancellationToken) => + { + return Task.FromResult(new TextToSpeechResponse([new DataContent(new byte[] { 1, 2, 3 }, "audio/mpeg")])); + }, + }; + + using ITextToSpeechClient client = innerClient + .AsBuilder() + .UseLogging() + .Build(services); + + await client.GetAudioAsync( + "Hello, world!", + new TextToSpeechOptions { VoiceId = "alloy" }); + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + // Invocation is logged at Trace level with options, but completion avoids + // serializing binary audio data, so it logs the same as Debug level. + Assert.Collection(logs, + entry => Assert.True(entry.Message.Contains($"{nameof(ITextToSpeechClient.GetAudioAsync)} invoked:") && entry.Message.Contains("\"voiceId\": \"alloy\"")), + entry => Assert.Contains($"{nameof(ITextToSpeechClient.GetAudioAsync)} completed.", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.True(entry.Message.Contains($"{nameof(ITextToSpeechClient.GetAudioAsync)} invoked.") && !entry.Message.Contains("\"voiceId\": \"alloy\"")), + entry => Assert.Contains($"{nameof(ITextToSpeechClient.GetAudioAsync)} completed.", entry.Message)); + } + else + { + Assert.Empty(logs); + } + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task GetStreamingAudioAsync_LogsUpdateReceived(LogLevel level) + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + + using ITextToSpeechClient innerClient = new TestTextToSpeechClient + { + GetStreamingAudioAsyncCallback = (text, options, cancellationToken) => GetUpdatesAsync() + }; + + static async IAsyncEnumerable GetUpdatesAsync() + { + await Task.Yield(); + yield return new([new DataContent(new byte[] { 1 }, "audio/mpeg")]); + yield return new([new DataContent(new byte[] { 2 }, "audio/mpeg")]); + } + + using ITextToSpeechClient client = innerClient + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + + await foreach (var update in client.GetStreamingAudioAsync( + "Hello, world!", + new TextToSpeechOptions { VoiceId = "alloy" })) + { + // nop + } + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + // Invocation is logged at Trace level with options, but streaming updates + // avoid serializing binary audio data, so they log the same as Debug level. + Assert.Collection(logs, + entry => Assert.True(entry.Message.Contains($"{nameof(ITextToSpeechClient.GetStreamingAudioAsync)} invoked:") && entry.Message.Contains("\"voiceId\": \"alloy\"")), + entry => Assert.Contains($"{nameof(ITextToSpeechClient.GetStreamingAudioAsync)} received update.", entry.Message), + entry => Assert.Contains($"{nameof(ITextToSpeechClient.GetStreamingAudioAsync)} received update.", entry.Message), + entry => Assert.Contains($"{nameof(ITextToSpeechClient.GetStreamingAudioAsync)} completed.", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.True(entry.Message.Contains($"{nameof(ITextToSpeechClient.GetStreamingAudioAsync)} invoked.") && !entry.Message.Contains("voiceId")), + entry => Assert.Contains($"{nameof(ITextToSpeechClient.GetStreamingAudioAsync)} received update.", entry.Message), + entry => Assert.Contains($"{nameof(ITextToSpeechClient.GetStreamingAudioAsync)} received update.", entry.Message), + entry => Assert.Contains($"{nameof(ITextToSpeechClient.GetStreamingAudioAsync)} completed.", entry.Message)); + } + else + { + Assert.Empty(logs); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/OpenTelemetryTextToSpeechClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/OpenTelemetryTextToSpeechClientTests.cs new file mode 100644 index 00000000000..a477f33e3ec --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/OpenTelemetryTextToSpeechClientTests.cs @@ -0,0 +1,192 @@ +// 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; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using OpenTelemetry.Trace; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenTelemetryTextToSpeechClientTests +{ + [Fact] + public void InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new OpenTelemetryTextToSpeechClient(null!)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ExpectedInformationLogged_Async(bool streaming, bool enableSensitiveData) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var innerClient = new TestTextToSpeechClient + { + GetAudioAsyncCallback = async (text, options, cancellationToken) => + { + await Task.Yield(); + return new([new DataContent(new byte[] { 1, 2, 3 }, "audio/mpeg")]) + { + Usage = new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + }, + }; + }, + + GetStreamingAudioAsyncCallback = TestClientStreamAsync, + + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(TextToSpeechClientMetadata) ? new TextToSpeechClientMetadata("testservice", new Uri("http://localhost:12345/something"), "amazingmodel") : + null, + }; + + static async IAsyncEnumerable TestClientStreamAsync( + string text, TextToSpeechOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + yield return new([new DataContent(new byte[] { 1 }, "audio/mpeg")]); + yield return new() + { + Contents = + [ + new DataContent(new byte[] { 2 }, "audio/mpeg"), + new UsageContent(new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + }), + ] + }; + } + + using var client = innerClient + .AsBuilder() + .UseOpenTelemetry(null, sourceName, configure: instance => + { + instance.EnableSensitiveData = enableSensitiveData; + }) + .Build(); + + TextToSpeechOptions options = new() + { + ModelId = "mycoolttsmodel", + AdditionalProperties = new() + { + ["service_tier"] = "value1", + ["SomethingElse"] = "value2", + }, + }; + + if (streaming) + { + await foreach (var update in client.GetStreamingAudioAsync("Hello, world!", options)) + { + // consume + } + } + else + { + await client.GetAudioAsync("Hello, world!", options); + } + + var activity = Assert.Single(activities); + + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + + Assert.Equal("localhost", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); + + Assert.Equal("generate_content mycoolttsmodel", activity.DisplayName); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.provider.name")); + + Assert.Equal("mycoolttsmodel", activity.GetTagItem("gen_ai.request.model")); + Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse")); + + Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens")); + Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens")); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExceptionLogged_Async(bool streaming) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var collector = new FakeLogCollector(); + using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + + var expectedException = new InvalidOperationException("test exception message"); + + using var innerClient = new TestTextToSpeechClient + { + GetAudioAsyncCallback = (text, options, cancellationToken) => throw expectedException, + GetStreamingAudioAsyncCallback = (text, options, cancellationToken) => throw expectedException, + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(TextToSpeechClientMetadata) ? new TextToSpeechClientMetadata("testservice", new Uri("http://localhost:12345"), "testmodel") : + null, + }; + + using var client = innerClient + .AsBuilder() + .UseOpenTelemetry(loggerFactory, sourceName) + .Build(); + + if (streaming) + { + await Assert.ThrowsAsync(async () => + { + await foreach (var update in client.GetStreamingAudioAsync("Hello")) + { + _ = update; + } + }); + } + else + { + await Assert.ThrowsAsync(() => + client.GetAudioAsync("Hello")); + } + + var activity = Assert.Single(activities); + + // Existing error behavior is preserved + Assert.Equal(expectedException.GetType().FullName, activity.GetTagItem("error.type")); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + + // Exception is logged via ILogger + var logEntry = Assert.Single(collector.GetSnapshot()); + Assert.Equal("gen_ai.client.operation.exception", logEntry.Id.Name); + Assert.Equal(LogLevel.Warning, logEntry.Level); + Assert.Same(expectedException, logEntry.Exception); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/SingletonTextToSpeechClientExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/SingletonTextToSpeechClientExtensions.cs new file mode 100644 index 00000000000..dd053d9ea84 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/SingletonTextToSpeechClientExtensions.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.AI; + +public static class SingletonTextToSpeechClientExtensions +{ + public static TextToSpeechClientBuilder UseSingletonMiddleware(this TextToSpeechClientBuilder builder) + => builder.Use((inner, services) + => new TextToSpeechClientDependencyInjectionPatterns.SingletonMiddleware(inner, services)); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/TextToSpeechClientDependencyInjectionPatterns.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/TextToSpeechClientDependencyInjectionPatterns.cs new file mode 100644 index 00000000000..09327d485ce --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/TextToSpeech/TextToSpeechClientDependencyInjectionPatterns.cs @@ -0,0 +1,178 @@ +// 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 Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextToSpeechClientDependencyInjectionPatterns +{ + private IServiceCollection ServiceCollection { get; } = new ServiceCollection(); + + [Fact] + public void CanRegisterSingletonUsingFactory() + { + // Arrange/Act + ServiceCollection.AddTextToSpeechClient(services => new TestTextToSpeechClient { Services = services }) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance1Copy = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerClient); + } + + [Fact] + public void CanRegisterSingletonUsingSharedInstance() + { + // Arrange/Act + using var singleton = new TestTextToSpeechClient(); + ServiceCollection.AddTextToSpeechClient(singleton) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance1Copy = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerClient); + } + + [Fact] + public void CanRegisterKeyedSingletonUsingFactory() + { + // Arrange/Act + ServiceCollection.AddKeyedTextToSpeechClient("mykey", services => new TestTextToSpeechClient { Services = services }) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + Assert.Null(services.GetService()); + + var instance1 = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance1Copy = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance2 = scope2.ServiceProvider.GetRequiredKeyedService("mykey"); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerClient); + } + + [Fact] + public void CanRegisterKeyedSingletonUsingSharedInstance() + { + // Arrange/Act + using var singleton = new TestTextToSpeechClient(); + ServiceCollection.AddKeyedTextToSpeechClient("mykey", singleton) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + Assert.Null(services.GetService()); + + var instance1 = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance1Copy = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance2 = scope2.ServiceProvider.GetRequiredKeyedService("mykey"); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerClient); + } + + [Theory] + [InlineData(null)] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void AddTextToSpeechClient_RegistersExpectedLifetime(ServiceLifetime? lifetime) + { + ServiceCollection sc = new(); + ServiceLifetime expectedLifetime = lifetime ?? ServiceLifetime.Singleton; + TextToSpeechClientBuilder builder = lifetime.HasValue + ? sc.AddTextToSpeechClient(services => new TestTextToSpeechClient(), lifetime.Value) + : sc.AddTextToSpeechClient(services => new TestTextToSpeechClient()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(ITextToSpeechClient), sd.ServiceType); + Assert.False(sd.IsKeyedService); + Assert.Null(sd.ImplementationInstance); + Assert.NotNull(sd.ImplementationFactory); + Assert.IsType(sd.ImplementationFactory(null!)); + Assert.Equal(expectedLifetime, sd.Lifetime); + } + + [Theory] + [InlineData(null)] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void AddKeyedTextToSpeechClient_RegistersExpectedLifetime(ServiceLifetime? lifetime) + { + ServiceCollection sc = new(); + ServiceLifetime expectedLifetime = lifetime ?? ServiceLifetime.Singleton; + TextToSpeechClientBuilder builder = lifetime.HasValue + ? sc.AddKeyedTextToSpeechClient("key", services => new TestTextToSpeechClient(), lifetime.Value) + : sc.AddKeyedTextToSpeechClient("key", services => new TestTextToSpeechClient()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(ITextToSpeechClient), sd.ServiceType); + Assert.True(sd.IsKeyedService); + Assert.Equal("key", sd.ServiceKey); + Assert.Null(sd.KeyedImplementationInstance); + Assert.NotNull(sd.KeyedImplementationFactory); + Assert.IsType(sd.KeyedImplementationFactory(null!, null!)); + Assert.Equal(expectedLifetime, sd.Lifetime); + } + + [Fact] + public void AddKeyedTextToSpeechClient_WorksWithNullServiceKey() + { + ServiceCollection sc = new(); + sc.AddKeyedTextToSpeechClient(null, _ => new TestTextToSpeechClient()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(ITextToSpeechClient), sd.ServiceType); + Assert.False(sd.IsKeyedService); + Assert.Null(sd.ServiceKey); + Assert.Null(sd.ImplementationInstance); + Assert.NotNull(sd.ImplementationFactory); + Assert.IsType(sd.ImplementationFactory(null!)); + Assert.Equal(ServiceLifetime.Singleton, sd.Lifetime); + } + + public class SingletonMiddleware(ITextToSpeechClient inner, IServiceProvider services) : DelegatingTextToSpeechClient(inner) + { + public new ITextToSpeechClient InnerClient => base.InnerClient; + public IServiceProvider Services => services; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs index f2f0d85c458..2220fa25b99 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs @@ -212,7 +212,7 @@ async Task Verify(IAsyncEnumerable results) Assert.Equal(_sampleFiles.Count, ingestionResults.Count); Assert.All(ingestionResults, result => Assert.NotEmpty(result.DocumentId)); - IngestionResult ingestionResult = Assert.Single(ingestionResults.Where(result => !result.Succeeded)); + IngestionResult ingestionResult = Assert.Single(ingestionResults, result => !result.Succeeded); Assert.IsType(ingestionResult.Exception); AssertErrorActivities(activities, expectedFailedActivitiesCount: 1); diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs index 0600a00216d..0395c470ce0 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs @@ -134,10 +134,9 @@ public async Task IncrementalIngestion_WithManyRecords_DeletesAllPreExistingChun IngestionDocument document = new(documentId); - // Create more chunks than the MaxTopCount (1000) to test pagination - // We create 2500 chunks to ensure multiple batches + // Create enough chunks to exercise the incremental ingestion delete-all behavior in DEBUG builds List> chunks = []; - for (int i = 0; i < 2500; i++) + for (int i = 0; i < 50; i++) { chunks.Add(new($"chunk {i}", document)); } diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveAddressesTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveAddressesTests.cs index c2d033ecdae..0a6c97a26c1 100644 --- a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveAddressesTests.cs +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveAddressesTests.cs @@ -57,6 +57,9 @@ public async Task ResolveIPv4_NoSuchName_Success(bool includeSoa) [Theory] [InlineData("www.resolveipv4.com")] [InlineData("www.resolveipv4.com.")] + [InlineData("notlocalhost")] + [InlineData("notlocalhost.")] + [InlineData("notinvalid.")] [InlineData("www.ř.com")] public async Task ResolveIPv4_Simple_Success(string name) { @@ -220,15 +223,39 @@ public async Task ResolveIP_InvalidAddressFamily_Throws() } [Theory] - [InlineData(AddressFamily.InterNetwork, "127.0.0.1")] - [InlineData(AddressFamily.InterNetworkV6, "::1")] - public async Task ResolveIP_Localhost_ReturnsLoopback(AddressFamily family, string addressAsString) + [InlineData("localhost", AddressFamily.InterNetwork, "127.0.0.1")] + [InlineData("localhost", AddressFamily.InterNetworkV6, "::1")] + [InlineData("localhost.", AddressFamily.InterNetwork, "127.0.0.1")] + [InlineData("inner.localhost.", AddressFamily.InterNetwork, "127.0.0.1")] + [InlineData("inner.localhost", AddressFamily.InterNetwork, "127.0.0.1")] + [InlineData("invalid", AddressFamily.InterNetwork, null)] + [InlineData("invalid", AddressFamily.InterNetworkV6, null)] + [InlineData("invalid.", AddressFamily.InterNetwork, null)] + [InlineData("inner.invalid.", AddressFamily.InterNetwork, null)] + [InlineData("inner.invalid", AddressFamily.InterNetwork, null)] + public async Task ResolveIP_SpecialName(string localhost, AddressFamily family, string? addressAsString) { - IPAddress address = IPAddress.Parse(addressAsString); - AddressResult[] results = await Resolver.ResolveIPAddressesAsync("localhost", family); - AddressResult result = Assert.Single(results); + IPAddress? address = addressAsString != null ? IPAddress.Parse(addressAsString) : null; - Assert.Equal(address, result.Address); + bool serverCalled = false; + _ = DnsServer.ProcessUdpRequest(builder => + { + serverCalled = true; + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(localhost, family); + Assert.False(serverCalled, "Special name resolution should not call the DNS server."); + + if (address == null) + { + Assert.Empty(results); + } + else + { + AddressResult result = Assert.Single(results); + Assert.Equal(address, result.Address); + } } [Fact] From 088ca0bb4ff43d9eb8d95144a699d7cfbf3fbc71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:04:41 +0000 Subject: [PATCH 9/9] Revert to SearchableAIFunctionDeclaration design, remove DeferredTools/NonDeferredTools from HostedToolSearchTool Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Agent-Logs-Url: https://github.com/dotnet/extensions/sessions/7a29d49e-c422-4fe7-81f4-366bd781b460 --- .../DelegatingAIFunctionDeclaration.cs | 5 +- .../SearchableAIFunctionDeclaration.cs | 62 ++++++ .../Tools/HostedToolSearchTool.cs | 35 --- .../Microsoft.Extensions.AI.OpenAI.json | 2 +- ...icrosoftExtensionsAIResponsesExtensions.cs | 9 +- .../OpenAIResponsesChatClient.cs | 16 +- .../SearchableAIFunctionDeclarationTests.cs | 108 ++++++++++ .../Tools/HostedToolSearchToolTests.cs | 32 --- .../OpenAIConversionTests.cs | 76 ++----- .../OpenAIResponseClientIntegrationTests.cs | 4 +- .../OpenAIResponseClientTests.cs | 200 ++---------------- 11 files changed, 224 insertions(+), 325 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs index 38ebcf0ffd9..727e29d766b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -11,7 +13,8 @@ namespace Microsoft.Extensions.AI; /// /// Provides an optional base class for an that passes through calls to another instance. /// -internal class DelegatingAIFunctionDeclaration : AIFunctionDeclaration // could be made public in the future if there's demand +[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] +public class DelegatingAIFunctionDeclaration : AIFunctionDeclaration { /// /// Initializes a new instance of the class as a wrapper around . diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs new file mode 100644 index 00000000000..c49b723e5bf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs @@ -0,0 +1,62 @@ +// 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; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an that signals to supporting AI services that deferred +/// loading should be used when tool search is enabled. Only the function's name and description are sent initially; +/// the full JSON schema is loaded on demand by the service when the model selects this tool. +/// +/// +/// This class is a marker/decorator that signals to a supporting provider that the function should be +/// sent with deferred loading (only name and description upfront). Use to create +/// a complete tool list including a and wrapped functions. +/// +[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class SearchableAIFunctionDeclaration : DelegatingAIFunctionDeclaration +{ + /// + /// Initializes a new instance of the class. + /// + /// The represented by this instance. + /// An optional namespace for grouping related tools in the tool search index. + /// is . + public SearchableAIFunctionDeclaration(AIFunctionDeclaration innerFunction, string? namespaceName = null) + : base(innerFunction) + { + Namespace = namespaceName; + } + + /// Gets the optional namespace this function belongs to, for grouping related tools in the tool search index. + public string? Namespace { get; } + + /// + /// Creates a complete tool list with a and the given functions wrapped as . + /// + /// The functions to include as searchable tools. + /// An optional namespace for grouping related tools. + /// Any additional properties to pass to the . + /// A list of instances ready for use in . + /// is . + public static IList CreateToolSet( + IEnumerable functions, + string? namespaceName = null, + IReadOnlyDictionary? toolSearchProperties = null) + { + _ = Throw.IfNull(functions); + + var tools = new List { new HostedToolSearchTool(toolSearchProperties) }; + foreach (var fn in functions) + { + tools.Add(new SearchableAIFunctionDeclaration(fn, namespaceName)); + } + + return tools; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs index 50c2465e465..4fd90e06449 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs @@ -9,15 +9,8 @@ namespace Microsoft.Extensions.AI; /// Represents a hosted tool that can be specified to an AI service to enable it to search for and selectively load tool definitions on demand. /// -/// /// This tool does not itself implement tool search. It is a marker that can be used to inform a service /// that tool search should be enabled, reducing token usage by deferring full tool schema loading until the model requests it. -/// -/// -/// By default, when a is present in the tools list, all other tools are treated -/// as having deferred loading enabled. Use and to control -/// which tools have deferred loading on a per-tool basis. -/// /// [Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)] public class HostedToolSearchTool : AITool @@ -42,32 +35,4 @@ public HostedToolSearchTool(IReadOnlyDictionary? additionalProp /// public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; - - /// - /// Gets or sets the list of tool names for which deferred loading should be enabled. - /// - /// - /// - /// The default value is , which enables deferred loading for all tools in the tools list. - /// - /// - /// When non-null, only tools whose names appear in this list will have deferred loading enabled, - /// unless they also appear in . - /// - /// - public IList? DeferredTools { get; set; } - - /// - /// Gets or sets the list of tool names for which deferred loading should be disabled. - /// - /// - /// - /// The default value is , which means no tools are excluded from deferred loading. - /// - /// - /// When non-null, tools whose names appear in this list will not have deferred loading enabled, - /// even if they also appear in . - /// - /// - public IList? NonDeferredTools { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json index 2decf2dc025..7d0e39492d9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json @@ -100,7 +100,7 @@ "Stage": "Experimental" }, { - "Member": "static OpenAI.Responses.ResponseTool? OpenAI.Responses.MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(this Microsoft.Extensions.AI.AITool tool, Microsoft.Extensions.AI.ChatOptions? options = null);", + "Member": "static OpenAI.Responses.ResponseTool? OpenAI.Responses.MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(this Microsoft.Extensions.AI.AITool tool);", "Stage": "Experimental" } ] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index b333c349595..419b65aaecc 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -26,19 +26,14 @@ public static FunctionTool AsOpenAIResponseTool(this AIFunctionDeclaration funct /// Creates an OpenAI from an . /// The tool to convert. - /// Optional chat options providing context for the conversion. When the tools list includes a , function tools may have deferred loading applied. /// An OpenAI representing or if there is no mapping. /// is . /// /// This method is only able to create s for types /// it's aware of, namely all of those available from the Microsoft.Extensions.AI.Abstractions library. /// - public static ResponseTool? AsOpenAIResponseTool(this AITool tool, ChatOptions? options = null) - { - _ = Throw.IfNull(tool); - - return OpenAIResponsesChatClient.ToResponseTool(tool, options); - } + public static ResponseTool? AsOpenAIResponseTool(this AITool tool) => + OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(tool)); /// /// Creates an OpenAI from a . diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index f5350eafae4..2177eae36e3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -18,6 +18,7 @@ using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; +using OpenAI; using OpenAI.Responses; #pragma warning disable S1226 // Method parameters, caught exceptions and foreach variables' initial values should not be ignored @@ -702,7 +703,20 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo return rtat.Tool; case AIFunctionDeclaration aiFunction: - return ToResponseTool(aiFunction, options); + var functionTool = ToResponseTool(aiFunction, options); + if (tool.GetService() is { } searchable) + { + functionTool.Patch.Set("$.defer_loading"u8, "true"u8); + if (searchable.Namespace is { } ns) + { + functionTool.Patch.Set("$.namespace"u8, JsonSerializer.SerializeToUtf8Bytes(ns, OpenAIJsonContext.Default.String).AsSpan()); + } + } + + return functionTool; + + case HostedToolSearchTool: + return ModelReaderWriter.Read(BinaryData.FromString("""{"type": "tool_search"}"""), ModelReaderWriterOptions.Json, OpenAIContext.Default)!; case HostedWebSearchTool webSearchTool: return new WebSearchTool diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs new file mode 100644 index 00000000000..a708ceee119 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.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; +using Xunit; + +namespace Microsoft.Extensions.AI.Functions; + +public class SearchableAIFunctionDeclarationTests +{ + [Fact] + public void Constructor_NullFunction_ThrowsArgumentNullException() + { + Assert.Throws("innerFunction", () => new SearchableAIFunctionDeclaration(null!)); + } + + [Fact] + public void Constructor_DelegatesToInnerFunction_Properties() + { + var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var wrapper = new SearchableAIFunctionDeclaration(inner); + + Assert.Equal(inner.Name, wrapper.Name); + Assert.Equal(inner.Description, wrapper.Description); + Assert.Equal(inner.JsonSchema, wrapper.JsonSchema); + Assert.Equal(inner.ReturnJsonSchema, wrapper.ReturnJsonSchema); + Assert.Same(inner.AdditionalProperties, wrapper.AdditionalProperties); + Assert.Equal(inner.ToString(), wrapper.ToString()); + } + + [Fact] + public void Namespace_DefaultIsNull() + { + var inner = AIFunctionFactory.Create(() => 42); + var wrapper = new SearchableAIFunctionDeclaration(inner); + + Assert.Null(wrapper.Namespace); + } + + [Fact] + public void Namespace_Roundtrips() + { + var inner = AIFunctionFactory.Create(() => 42); + var wrapper = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace"); + + Assert.Equal("myNamespace", wrapper.Namespace); + } + + [Fact] + public void GetService_ReturnsSelf() + { + var inner = AIFunctionFactory.Create(() => 42); + var wrapper = new SearchableAIFunctionDeclaration(inner); + + Assert.Same(wrapper, wrapper.GetService()); + } + + [Fact] + public void CreateToolSet_NullFunctions_Throws() + { + Assert.Throws("functions", () => SearchableAIFunctionDeclaration.CreateToolSet(null!)); + } + + [Fact] + public void CreateToolSet_ReturnsHostedToolSearchToolFirst_ThenWrappedFunctions() + { + var f1 = AIFunctionFactory.Create(() => 1, "F1"); + var f2 = AIFunctionFactory.Create(() => 2, "F2"); + + var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1, f2]); + + Assert.Equal(3, tools.Count); + Assert.IsType(tools[0]); + Assert.Empty(tools[0].AdditionalProperties); + + var s1 = Assert.IsType(tools[1]); + Assert.Equal("F1", s1.Name); + Assert.Null(s1.Namespace); + + var s2 = Assert.IsType(tools[2]); + Assert.Equal("F2", s2.Name); + Assert.Null(s2.Namespace); + } + + [Fact] + public void CreateToolSet_WithNamespace_AppliesNamespaceToAll() + { + var f1 = AIFunctionFactory.Create(() => 1, "F1"); + + var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1], namespaceName: "ns"); + + var s1 = Assert.IsType(tools[1]); + Assert.Equal("ns", s1.Namespace); + } + + [Fact] + public void CreateToolSet_WithAdditionalProperties_PassesToHostedToolSearchTool() + { + var props = new Dictionary { ["key"] = "value" }; + var f1 = AIFunctionFactory.Create(() => 1, "F1"); + + var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1], toolSearchProperties: props); + + var toolSearch = Assert.IsType(tools[0]); + Assert.Same(props, toolSearch.AdditionalProperties); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs index 24cde84d490..f3a32dc8c84 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs @@ -35,36 +35,4 @@ public void Constructor_NullAdditionalProperties_UsesEmpty() Assert.Empty(tool.AdditionalProperties); } - - [Fact] - public void DeferredTools_DefaultIsNull() - { - var tool = new HostedToolSearchTool(); - Assert.Null(tool.DeferredTools); - } - - [Fact] - public void DeferredTools_Roundtrips() - { - var tool = new HostedToolSearchTool(); - var list = new List { "func1", "func2" }; - tool.DeferredTools = list; - Assert.Same(list, tool.DeferredTools); - } - - [Fact] - public void NonDeferredTools_DefaultIsNull() - { - var tool = new HostedToolSearchTool(); - Assert.Null(tool.NonDeferredTools); - } - - [Fact] - public void NonDeferredTools_Roundtrips() - { - var tool = new HostedToolSearchTool(); - var list = new List { "func3" }; - tool.NonDeferredTools = list; - Assert.Same(list, tool.NonDeferredTools); - } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 6a320508322..3092c66d83b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -602,23 +602,23 @@ public void AsOpenAIResponseTool_WithHostedToolSearchTool_ProducesValidToolSearc } [Fact] - public void AsOpenAIResponseTool_WithHostedToolSearchTool_CachesResult() + public void AsOpenAIResponseTool_WithHostedToolSearchTool_ProducesNewInstanceEachTime() { var result1 = new HostedToolSearchTool().AsOpenAIResponseTool(); var result2 = new HostedToolSearchTool().AsOpenAIResponseTool(); Assert.NotNull(result1); - Assert.Same(result1, result2); + Assert.NotNull(result2); + Assert.NotSame(result1, result2); } [Fact] - public void AsOpenAIResponseTool_AllToolsDeferred_WhenBothListsNull() + public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclaration_PatchesDeferLoading() { - var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); - var toolSearch = new HostedToolSearchTool(); - var options = new ChatOptions { Tools = [toolSearch, func] }; + var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var searchable = new SearchableAIFunctionDeclaration(inner); - var result = func.AsOpenAIResponseTool(options); + var result = ((AITool)searchable).AsOpenAIResponseTool(); Assert.NotNull(result); var functionTool = Assert.IsType(result); @@ -628,70 +628,30 @@ public void AsOpenAIResponseTool_AllToolsDeferred_WhenBothListsNull() } [Fact] - public void AsOpenAIResponseTool_NoDeferLoading_WhenNoHostedToolSearchTool() + public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclarationWithNamespace_PatchesNamespace() { - var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); - var options = new ChatOptions { Tools = [func] }; + var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); + var searchable = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace"); - var result = func.AsOpenAIResponseTool(options); + var result = ((AITool)searchable).AsOpenAIResponseTool(); Assert.NotNull(result); var functionTool = Assert.IsType(result); var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString(); - Assert.DoesNotContain("defer_loading", json); - } - - [Fact] - public void AsOpenAIResponseTool_OnlyDeferredToolsGetDeferLoading() - { - var func1 = AIFunctionFactory.Create(() => 1, "Func1"); - var func2 = AIFunctionFactory.Create(() => 2, "Func2"); - var toolSearch = new HostedToolSearchTool { DeferredTools = ["Func1"] }; - var options = new ChatOptions { Tools = [toolSearch, func1, func2] }; - - var result1 = func1.AsOpenAIResponseTool(options); - var result2 = func2.AsOpenAIResponseTool(options); - - var json1 = ModelReaderWriter.Write(result1!, ModelReaderWriterOptions.Json).ToString(); - Assert.Contains("defer_loading", json1); - - var json2 = ModelReaderWriter.Write(result2!, ModelReaderWriterOptions.Json).ToString(); - Assert.DoesNotContain("defer_loading", json2); - } - - [Fact] - public void AsOpenAIResponseTool_NonDeferredToolsExcluded() - { - var func1 = AIFunctionFactory.Create(() => 1, "Func1"); - var func2 = AIFunctionFactory.Create(() => 2, "Func2"); - var toolSearch = new HostedToolSearchTool { NonDeferredTools = ["Func2"] }; - var options = new ChatOptions { Tools = [toolSearch, func1, func2] }; - - var result1 = func1.AsOpenAIResponseTool(options); - var result2 = func2.AsOpenAIResponseTool(options); - - var json1 = ModelReaderWriter.Write(result1!, ModelReaderWriterOptions.Json).ToString(); - Assert.Contains("defer_loading", json1); - - var json2 = ModelReaderWriter.Write(result2!, ModelReaderWriterOptions.Json).ToString(); - Assert.DoesNotContain("defer_loading", json2); + Assert.Contains("namespace", json); + Assert.Contains("myNamespace", json); } [Fact] - public void AsOpenAIResponseTool_BothLists_DisableTakesPrecedence() + public void AsOpenAIResponseTool_WithPlainAIFunction_NoDeferLoading() { - var func = AIFunctionFactory.Create(() => 42, "MyFunc"); - var toolSearch = new HostedToolSearchTool - { - DeferredTools = ["MyFunc"], - NonDeferredTools = ["MyFunc"], - }; - var options = new ChatOptions { Tools = [toolSearch, func] }; + var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description"); - var result = func.AsOpenAIResponseTool(options); + var result = ((AITool)func).AsOpenAIResponseTool(); Assert.NotNull(result); - var json = ModelReaderWriter.Write(result!, ModelReaderWriterOptions.Json).ToString(); + var functionTool = Assert.IsType(result); + var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString(); Assert.DoesNotContain("defer_loading", json); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index 995c9a9a86f..2bc3dd1017d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -770,8 +770,8 @@ public async Task UseToolSearch_WithDeferredFunctions() Tools = [ new HostedToolSearchTool(), - getWeather, - getTime, + new SearchableAIFunctionDeclaration(getWeather), + new SearchableAIFunctionDeclaration(getTime), ], }); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index d5fe1c7508f..2c6dacc86b0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -7161,7 +7161,7 @@ public async Task ToolSearchTool_OnlyToolSearch_NonStreaming() } [Fact] - public async Task ToolSearchTool_AllToolsDeferred_NonStreaming() + public async Task ToolSearchTool_SearchableFunctionsDeferred_NonStreaming() { const string Input = """ { @@ -7235,102 +7235,16 @@ public async Task ToolSearchTool_AllToolsDeferred_NonStreaming() using HttpClient httpClient = new(handler); using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); - var response = await client.GetResponseAsync("hello", new() - { - Tools = - [ - new HostedToolSearchTool(), - AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."), - AIFunctionFactory.Create(() => 42, "GetForecast", "Gets the forecast."), - ], - AdditionalProperties = new() { ["strict"] = true }, - }); - - Assert.NotNull(response); - Assert.Equal("Hello!", response.Text); - } - - [Fact] - public async Task ToolSearchTool_SpecificDeferredTools_NonStreaming() - { - const string Input = """ - { - "model": "gpt-4o-mini", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello" - } - ] - } - ], - "tools": [ - { - "type": "tool_search" - }, - { - "type": "function", - "name": "GetWeather", - "description": "Gets the weather.", - "parameters": { - "type": "object", - "required": [], - "properties": {}, - "additionalProperties": false - }, - "strict": true, - "defer_loading": true - }, - { - "type": "function", - "name": "GetForecast", - "description": "Gets the forecast.", - "parameters": { - "type": "object", - "required": [], - "properties": {}, - "additionalProperties": false - }, - "strict": true - } - ] - } - """; - - const string Output = """ - { - "id": "resp_001", - "object": "response", - "created_at": 1741892091, - "status": "completed", - "model": "gpt-4o-mini", - "output": [ - { - "type": "message", - "id": "msg_001", - "status": "completed", - "role": "assistant", - "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] - } - ] - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + var getWeather = AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."); + var getForecast = AIFunctionFactory.Create(() => 42, "GetForecast", "Gets the forecast."); var response = await client.GetResponseAsync("hello", new() { Tools = [ - new HostedToolSearchTool { DeferredTools = ["GetWeather"] }, - AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."), - AIFunctionFactory.Create(() => 42, "GetForecast", "Gets the forecast."), + new HostedToolSearchTool(), + new SearchableAIFunctionDeclaration(getWeather), + new SearchableAIFunctionDeclaration(getForecast), ], AdditionalProperties = new() { ["strict"] = true }, }); @@ -7340,7 +7254,7 @@ public async Task ToolSearchTool_SpecificDeferredTools_NonStreaming() } [Fact] - public async Task ToolSearchTool_NonDeferredExclusion_NonStreaming() + public async Task ToolSearchTool_MixedSearchableAndPlainFunctions_NonStreaming() { const string Input = """ { @@ -7413,106 +7327,16 @@ public async Task ToolSearchTool_NonDeferredExclusion_NonStreaming() using HttpClient httpClient = new(handler); using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); - var response = await client.GetResponseAsync("hello", new() - { - Tools = - [ - new HostedToolSearchTool { NonDeferredTools = ["ImportantTool"] }, - AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."), - AIFunctionFactory.Create(() => 42, "ImportantTool", "An important tool."), - ], - AdditionalProperties = new() { ["strict"] = true }, - }); - - Assert.NotNull(response); - Assert.Equal("Hello!", response.Text); - } - - [Fact] - public async Task ToolSearchTool_BothLists_DisableTakesPrecedence_NonStreaming() - { - const string Input = """ - { - "model": "gpt-4o-mini", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello" - } - ] - } - ], - "tools": [ - { - "type": "tool_search" - }, - { - "type": "function", - "name": "Func1", - "description": "First function.", - "parameters": { - "type": "object", - "required": [], - "properties": {}, - "additionalProperties": false - }, - "strict": true - }, - { - "type": "function", - "name": "Func2", - "description": "Second function.", - "parameters": { - "type": "object", - "required": [], - "properties": {}, - "additionalProperties": false - }, - "strict": true, - "defer_loading": true - } - ] - } - """; - - const string Output = """ - { - "id": "resp_001", - "object": "response", - "created_at": 1741892091, - "status": "completed", - "model": "gpt-4o-mini", - "output": [ - { - "type": "message", - "id": "msg_001", - "status": "completed", - "role": "assistant", - "content": [{"type": "output_text", "text": "Hello!", "annotations": []}] - } - ] - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + var getWeather = AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather."); + var importantTool = AIFunctionFactory.Create(() => 42, "ImportantTool", "An important tool."); var response = await client.GetResponseAsync("hello", new() { Tools = [ - new HostedToolSearchTool - { - DeferredTools = ["Func1", "Func2"], - NonDeferredTools = ["Func1"], - }, - AIFunctionFactory.Create(() => 1, "Func1", "First function."), - AIFunctionFactory.Create(() => 2, "Func2", "Second function."), + new HostedToolSearchTool(), + new SearchableAIFunctionDeclaration(getWeather), + importantTool, ], AdditionalProperties = new() { ["strict"] = true }, });