From 78f99eee8c02c599cd7eea2182abb4e0e5ca4482 Mon Sep 17 00:00:00 2001 From: Nikita Balabaev Date: Wed, 2 Jul 2025 16:29:25 +0700 Subject: [PATCH 01/71] Align EventId generation with M.E.Logging source-gen (#6566) Co-authored-by: Nikita --- .../Emission/Emitter.Method.cs | 4 ++- .../Generated/LogMethodTests.cs | 31 +++++++++++++++++++ .../TestClasses/EventNameTestExtensions.cs | 8 +++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs b/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs index 68549c11540..c970caf330f 100644 --- a/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs +++ b/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs @@ -91,7 +91,9 @@ private void GenLogMethod(LoggingMethod lm) } else { - OutLn($"new({GetNonRandomizedHashCode(eventName)}, {eventName}),"); + var eventNameToCalcId = string.IsNullOrWhiteSpace(lm.EventName) ? lm.Name : lm.EventName!; + var calculatedEventId = GetNonRandomizedHashCode(eventNameToCalcId); + OutLn($"new({calculatedEventId}, {eventName}),"); } OutLn($"{stateName},"); diff --git a/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs b/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs index 1d74dc68713..12771c42767 100644 --- a/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs +++ b/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs @@ -580,6 +580,37 @@ public void EventNameTests() Assert.Equal("M1_Event", logRecord.Id.Name); } + [Fact] + public void EventIdTests() + { + using var logger = Utils.GetLogger(); + var collector = logger.FakeLogCollector; + + collector.Clear(); + EventNameTestExtensions.M1(LogLevel.Warning, logger, "Eight"); + Assert.Equal(1, collector.Count); + + var firstEventId = collector.LatestRecord.Id; + Assert.NotEqual(0, firstEventId.Id); + Assert.Equal("M1_Event", firstEventId.Name); + + collector.Clear(); + EventNameTestExtensions.M1_Event(logger, "Nine"); + Assert.Equal(1, collector.Count); + + var secondEventId = collector.LatestRecord.Id; + Assert.Equal(firstEventId.Id, secondEventId.Id); // Same EventName means same generated EventId + Assert.Equal(nameof(EventNameTestExtensions.M1_Event), secondEventId.Name); + + collector.Clear(); + EventNameTestExtensions.M2(logger, "Ten"); + Assert.Equal(1, collector.Count); + + var thirdEventId = collector.LatestRecord.Id; + Assert.NotEqual(thirdEventId.Id, secondEventId.Id); // Different EventName means different generated EventId + Assert.Equal(nameof(EventNameTestExtensions.M2), thirdEventId.Name); + } + [Fact] public void NestedClassTests() { diff --git a/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs b/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs index 48385c1fd0a..83c239499e1 100644 --- a/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs +++ b/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs @@ -12,5 +12,13 @@ internal static partial class EventNameTestExtensions [LoggerMessage(EventName = "M1_Event")] public static partial void M1(LogLevel level, ILogger logger, string p0); + + // This one should have the same generated EventId as the method above + [LoggerMessage(LogLevel.Debug)] + public static partial void M1_Event(ILogger logger, string p0); + + // This one should have different generated EventId as the methods above + [LoggerMessage(LogLevel.Error)] + public static partial void M2(ILogger logger, string p0); } } From 0a55b139e52b732faea8da8e078dc1b69b8b96b9 Mon Sep 17 00:00:00 2001 From: Amadeusz Lechniak Date: Wed, 2 Jul 2025 14:41:38 +0200 Subject: [PATCH 02/71] Add resiliency mechanism to CPU and memory utilization checks (#6528) * Add RetryingLinuxUtilizationParser * Switch approach to use retries in Provider class * Refactor and add test * Refactor --- .../Linux/LinuxUtilizationProvider.cs | 77 ++++++++++- .../Linux/LinuxUtilizationProviderTests.cs | 130 ++++++++++++++++++ 2 files changed, 200 insertions(+), 7 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs index 4090bbb5619..af13b8100ba 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs @@ -2,7 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Diagnostics.Metrics; +using System.Linq; +using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -33,6 +36,10 @@ internal sealed class LinuxUtilizationProvider : ISnapshotProvider private readonly double _scaleRelativeToCpuRequest; private readonly double _scaleRelativeToCpuRequestForTrackerApi; + private readonly TimeSpan _retryInterval = TimeSpan.FromMinutes(5); + private DateTimeOffset _lastFailure = DateTimeOffset.MinValue; + private int _measurementsUnavailable; + private DateTimeOffset _refreshAfterCpu; private DateTimeOffset _refreshAfterMemory; @@ -94,18 +101,44 @@ public LinuxUtilizationProvider(IOptions options, ILi // Initialize the counters _cpuUtilizationLimit100PercentExceededCounter = meter.CreateCounter("cpu_utilization_limit_100_percent_exceeded"); _cpuUtilizationLimit110PercentExceededCounter = meter.CreateCounter("cpu_utilization_limit_110_percent_exceeded"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: () => CpuUtilizationLimit(cpuLimit), unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, observeValue: () => CpuUtilizationWithoutHostDelta() / cpuRequest, unit: "1"); + + _ = meter.CreateObservableGauge( + ResourceUtilizationInstruments.ContainerCpuLimitUtilization, + () => GetMeasurementWithRetry(() => CpuUtilizationLimit(cpuLimit)), + "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilizationWithoutHostDelta() / cpuRequest), + unit: "1"); } else { - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: () => CpuUtilization() * _scaleRelativeToCpuLimit, unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, observeValue: () => CpuUtilization() * _scaleRelativeToCpuRequest, unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessCpuUtilization, observeValue: () => CpuUtilization() * _scaleRelativeToCpuRequest, unit: "1"); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * _scaleRelativeToCpuLimit), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * _scaleRelativeToCpuRequest), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessCpuUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * _scaleRelativeToCpuRequest), + unit: "1"); } - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, observeValue: MemoryUtilization, unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessMemoryUtilization, observeValue: MemoryUtilization, unit: "1"); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, + observeValues: () => GetMeasurementWithRetry(() => MemoryUtilization()), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessMemoryUtilization, + observeValues: () => GetMeasurementWithRetry(() => MemoryUtilization()), + unit: "1"); // cpuRequest is a CPU request (aka guaranteed number of CPU units) for pod, for host its 1 core // cpuLimit is a CPU limit (aka max CPU units available) for a pod or for a host. @@ -288,4 +321,34 @@ public Snapshot GetSnapshot() userTimeSinceStart: TimeSpan.FromTicks((long)(cgroupTime / Hundred * _scaleRelativeToCpuRequestForTrackerApi)), memoryUsageInBytes: memoryUsed); } + + private IEnumerable> GetMeasurementWithRetry(Func func) + { + if (Volatile.Read(ref _measurementsUnavailable) == 1 && + _timeProvider.GetUtcNow() - _lastFailure < _retryInterval) + { + return Enumerable.Empty>(); + } + + try + { + double result = func(); + if (Volatile.Read(ref _measurementsUnavailable) == 1) + { + _ = Interlocked.Exchange(ref _measurementsUnavailable, 0); + } + + return new[] { new Measurement(result) }; + } + catch (Exception ex) when ( + ex is System.IO.FileNotFoundException || + ex is System.IO.DirectoryNotFoundException || + ex is System.UnauthorizedAccessException) + { + _lastFailure = _timeProvider.GetUtcNow(); + _ = Interlocked.Exchange(ref _measurementsUnavailable, 1); + + return Enumerable.Empty>(); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs index e6e9a282eca..6ee3c40d44d 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Time.Testing; using Microsoft.Shared.Instruments; using Microsoft.TestUtilities; using Moq; @@ -272,4 +273,133 @@ public void Provider_Registers_Instruments_CgroupV2_WithoutHostCpu() Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); Assert.Equal(1, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization).value); } + + [Fact] + public void Provider_GetMeasurementWithRetry_HandlesExceptionAndRecovers() + { + var meterName = Guid.NewGuid().ToString(); + var logger = new FakeLogger(); + var options = Options.Options.Create(new ResourceMonitoringOptions()); + using var meter = new Meter(nameof(Provider_GetMeasurementWithRetry_HandlesExceptionAndRecovers)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + + var callCount = 0; + var parserMock = new Mock(); + parserMock.Setup(p => p.GetMemoryUsageInBytes()).Returns(() => + { + callCount++; + if (callCount <= 1) + { + throw new FileNotFoundException("Simulated failure to read file"); + } + + return 420UL; + }); + parserMock.Setup(p => p.GetAvailableMemoryInBytes()).Returns(1000UL); + parserMock.Setup(p => p.GetCgroupRequestCpu()).Returns(10f); + parserMock.Setup(p => p.GetCgroupLimitedCpus()).Returns(12f); + + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + var provider = new LinuxUtilizationProvider(options, parserMock.Object, meterFactoryMock.Object, logger, fakeTime); + + using var listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + listener.EnableMeasurementEvents(instrument); + } + } + }; + + var samples = new List<(Instrument instrument, double value)>(); + listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + samples.Add((instrument, value)); + } + }); + + listener.Start(); + listener.RecordObservableInstruments(); + Assert.DoesNotContain(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + + fakeTime.Advance(TimeSpan.FromMinutes(1)); + listener.RecordObservableInstruments(); + Assert.DoesNotContain(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + + fakeTime.Advance(TimeSpan.FromMinutes(5)); + listener.RecordObservableInstruments(); + var metric = samples.SingleOrDefault(x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + Assert.Equal(0.42, metric.value); + + parserMock.Verify(p => p.GetMemoryUsageInBytes(), Times.Exactly(2)); + } + + [Fact] + public void Provider_GetMeasurementWithRetry_UnhandledException_DoesNotBlockFutureReads() + { + var meterName = Guid.NewGuid().ToString(); + var logger = new FakeLogger(); + var options = Options.Options.Create(new ResourceMonitoringOptions()); + using var meter = new Meter(nameof(Provider_GetMeasurementWithRetry_UnhandledException_DoesNotBlockFutureReads)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + + var callCount = 0; + var parserMock = new Mock(); + parserMock.Setup(p => p.GetMemoryUsageInBytes()).Returns(() => + { + callCount++; + if (callCount <= 2) + { + throw new InvalidOperationException("Simulated unhandled exception"); + } + + return 1234UL; + }); + parserMock.Setup(p => p.GetAvailableMemoryInBytes()).Returns(2000UL); + parserMock.Setup(p => p.GetCgroupRequestCpu()).Returns(10f); + parserMock.Setup(p => p.GetCgroupLimitedCpus()).Returns(12f); + + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + var provider = new LinuxUtilizationProvider(options, parserMock.Object, meterFactoryMock.Object, logger, fakeTime); + + using var listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + listener.EnableMeasurementEvents(instrument); + } + } + }; + + var samples = new List<(Instrument instrument, double value)>(); + listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + samples.Add((instrument, value)); + } + }); + + listener.Start(); + + Assert.Throws(() => listener.RecordObservableInstruments()); + Assert.DoesNotContain(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + + fakeTime.Advance(TimeSpan.FromMinutes(1)); + listener.RecordObservableInstruments(); + var metric = samples.SingleOrDefault(x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + Assert.Equal(1234f / 2000f, metric.value, 0.01f); + + parserMock.Verify(p => p.GetMemoryUsageInBytes(), Times.Exactly(3)); + } } From c3b85f80ce12f6d3b33949f191c86c6cd23e5d74 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 2 Jul 2025 11:16:01 -0400 Subject: [PATCH 03/71] Add DelegatingAIFunction (#6565) * Add DelegatingAIFunction To simplify scenarios where someone wants to augment an existing AIFunction's behavior, tweak what one of its properties returns, etc. * Address PR feedback --- .../Functions/DelegatingAIFunction.cs | 61 ++++++++++++ .../Microsoft.Extensions.AI.Abstractions.json | 52 +++++++++++ .../Functions/DelegatingAIFunctionTests.cs | 92 +++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.cs new file mode 100644 index 00000000000..a52c5acd959 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.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; +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable SA1202 // Elements should be ordered by access + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +public class DelegatingAIFunction : AIFunction +{ + /// + /// Initializes a new instance of the class as a wrapper around . + /// + /// The inner AI function to which all calls are delegated by default. + /// is . + protected DelegatingAIFunction(AIFunction innerFunction) + { + InnerFunction = Throw.IfNull(innerFunction); + } + + /// Gets the inner . + protected AIFunction InnerFunction { get; } + + /// + public override string Name => InnerFunction.Name; + + /// + public override string Description => InnerFunction.Description; + + /// + public override JsonElement JsonSchema => InnerFunction.JsonSchema; + + /// + public override JsonElement? ReturnJsonSchema => InnerFunction.ReturnJsonSchema; + + /// + public override JsonSerializerOptions JsonSerializerOptions => InnerFunction.JsonSerializerOptions; + + /// + public override MethodInfo? UnderlyingMethod => InnerFunction.UnderlyingMethod; + + /// + public override IReadOnlyDictionary AdditionalProperties => InnerFunction.AdditionalProperties; + + /// + public override string ToString() => InnerFunction.ToString(); + + /// + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + InnerFunction.InvokeAsync(arguments, cancellationToken); +} 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 79776b0ecb4..5e87edc01f9 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 @@ -1355,6 +1355,58 @@ } ] }, + { + "Type": "class Microsoft.Extensions.AI.DelegatingAIFunction : Microsoft.Extensions.AI.AIFunction", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DelegatingAIFunction.DelegatingAIFunction(Microsoft.Extensions.AI.AIFunction innerFunction);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.DelegatingAIFunction.InvokeCoreAsync(Microsoft.Extensions.AI.AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunction.ToString();", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.DelegatingAIFunction.InnerFunction { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.DelegatingAIFunction.AdditionalProperties { get; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunction.Description { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Text.Json.JsonElement Microsoft.Extensions.AI.DelegatingAIFunction.JsonSchema { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.DelegatingAIFunction.JsonSerializerOptions { get; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunction.Name { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Text.Json.JsonElement? Microsoft.Extensions.AI.DelegatingAIFunction.ReturnJsonSchema { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Reflection.MethodInfo? Microsoft.Extensions.AI.DelegatingAIFunction.UnderlyingMethod { get; }", + "Stage": "Stable" + } + ] + }, { "Type": "class Microsoft.Extensions.AI.DelegatingChatClient : Microsoft.Extensions.AI.IChatClient, System.IDisposable", "Stage": "Stable", diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs new file mode 100644 index 00000000000..cfad15efdc0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DelegatingAIFunctionTests +{ + [Fact] + public void Constructor_NullInnerFunction_ThrowsArgumentNullException() + { + Assert.Throws("innerFunction", () => new DerivedFunction(null!)); + } + + [Fact] + public void DefaultOverrides_DelegateToInnerFunction() + { + AIFunction expected = AIFunctionFactory.Create(() => 42); + DerivedFunction actual = new(expected); + + Assert.Same(expected, actual.InnerFunction); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.Description, actual.Description); + Assert.Equal(expected.JsonSchema, actual.JsonSchema); + Assert.Equal(expected.ReturnJsonSchema, actual.ReturnJsonSchema); + Assert.Same(expected.JsonSerializerOptions, actual.JsonSerializerOptions); + Assert.Same(expected.UnderlyingMethod, actual.UnderlyingMethod); + Assert.Same(expected.AdditionalProperties, actual.AdditionalProperties); + Assert.Equal(expected.ToString(), actual.ToString()); + } + + private sealed class DerivedFunction(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) + { + public new AIFunction InnerFunction => base.InnerFunction; + } + + [Fact] + public void Virtuals_AllOverridden() + { + Assert.All(typeof(DelegatingAIFunction).GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), m => + { + switch (m) + { + case MethodInfo methodInfo when methodInfo.IsVirtual && methodInfo.Name is not ("Finalize" or "Equals" or "GetHashCode"): + Assert.True(methodInfo.DeclaringType == typeof(DelegatingAIFunction), $"{methodInfo.Name} not overridden"); + break; + + case PropertyInfo propertyInfo when propertyInfo.GetMethod?.IsVirtual is true: + Assert.True(propertyInfo.DeclaringType == typeof(DelegatingAIFunction), $"{propertyInfo.Name} not overridden"); + break; + } + }); + } + + [Fact] + public async Task OverriddenInvocation_SuccessfullyInvoked() + { + bool innerInvoked = false; + AIFunction inner = AIFunctionFactory.Create(int () => + { + innerInvoked = true; + throw new Exception("uh oh"); + }, "TestFunction", "A test function for DelegatingAIFunction"); + + AIFunction actual = new OverridesInvocation(inner, (args, ct) => new ValueTask(84)); + + Assert.Equal(inner.Name, actual.Name); + Assert.Equal(inner.Description, actual.Description); + Assert.Equal(inner.JsonSchema, actual.JsonSchema); + Assert.Equal(inner.ReturnJsonSchema, actual.ReturnJsonSchema); + Assert.Same(inner.JsonSerializerOptions, actual.JsonSerializerOptions); + Assert.Same(inner.UnderlyingMethod, actual.UnderlyingMethod); + Assert.Same(inner.AdditionalProperties, actual.AdditionalProperties); + Assert.Equal(inner.ToString(), actual.ToString()); + + object? result = await actual.InvokeAsync(new(), CancellationToken.None); + Assert.Contains("84", result?.ToString()); + + Assert.False(innerInvoked); + } + + private sealed class OverridesInvocation(AIFunction innerFunction, Func> invokeAsync) : DelegatingAIFunction(innerFunction) + { + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + invokeAsync(arguments, cancellationToken); + } +} From 7854bf3b937373693869da5513e5447ff925c6a4 Mon Sep 17 00:00:00 2001 From: Joel Verhagen Date: Wed, 2 Jul 2025 11:55:01 -0400 Subject: [PATCH 04/71] Suppress flaky test until fixed (#6568) Found during another PR. Recurred 2 or 3 times. Worked around by retrying the CI build. Fixed tracked by https://github.com/dotnet/extensions/issues/6567. --- .../TimerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs index d0db83d943a..18d45449388 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs @@ -178,7 +178,7 @@ public async Task Change_WhenCalledAfterDisposeAsync_ReturnsFalse() Assert.False(t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1))); } - [Fact] + [Fact(Skip = "Flaky, https://github.com/dotnet/extensions/issues/6567")] public void CreateTimer_WhenDisposed_RemovesWaiterFromQueue() { var timer1Counter = 0; From c49b57b42701303d0b4fc8c6d692c6d8ab97ce37 Mon Sep 17 00:00:00 2001 From: Joel Verhagen Date: Wed, 2 Jul 2025 12:06:38 -0400 Subject: [PATCH 05/71] Create a project template for an MCP server (#6547) The conventions we're pushing for on NuGet are: 1. `PackAsTool` 2. `McpServer` package type (in addition to the default `DotnetTool`) 3. Embed server.json This provides an `mcpserver` template as part of `Microsoft.Extensions.AI.Templates`. This currently only covers a local MCP server, with stdio transport. --- src/ProjectTemplates/GeneratedContent.targets | 8 ++ .../Microsoft.Extensions.AI.Templates.csproj | 10 ++ .../McpServer-CSharp/.mcp/server.json | 20 ++++ .../.template.config/dotnetcli.host.json | 7 ++ .../.template.config/ide.host.json | 6 ++ .../.template.config/ide/icon.ico | Bin 0 -> 38045 bytes .../.template.config/template.json | 46 +++++++++ .../McpServer-CSharp.csproj.in | 32 ++++++ .../src/McpServer/McpServer-CSharp/Program.cs | 16 +++ .../src/McpServer/McpServer-CSharp/README.md | 82 +++++++++++++++ .../Tools/RandomNumberTools.cs | 18 ++++ .../McpServerSnapshotTests.cs | 95 ++++++++++++++++++ .../mcpserver/.mcp/server.json | 20 ++++ .../mcpserver/Program.cs | 16 +++ .../mcpserver/README.md | 82 +++++++++++++++ .../mcpserver/Tools/RandomNumberTools.cs | 18 ++++ .../mcpserver/mcpserver.csproj | 32 ++++++ 17 files changed, 508 insertions(+) create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index c3287408034..ce6ba2bc502 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -10,6 +10,7 @@ <_LocalChatTemplateVariant>aspire <_ChatWithCustomDataContentRoot>$(MSBuildThisFileDirectory)Microsoft.Extensions.AI.Templates\src\ChatWithCustomData\ + <_McpServerContentRoot>$(MSBuildThisFileDirectory)Microsoft.Extensions.AI.Templates\src\McpServer\ @@ -34,9 +35,11 @@ 1.14.0 11.6.0 9.4.1-beta.291 + 10.0.0-preview.5.25277.114 9.3.0 1.53.0 1.53.0-preview + 0.3.0-preview.1 5.1.18 1.12.0 0.1.10 @@ -64,9 +67,11 @@ TemplatePackageVersion_AzureIdentity=$(TemplatePackageVersion_AzureIdentity); TemplatePackageVersion_AzureSearchDocuments=$(TemplatePackageVersion_AzureSearchDocuments); TemplatePackageVersion_CommunityToolkitAspire=$(TemplatePackageVersion_CommunityToolkitAspire); + TemplatePackageVersion_MicrosoftExtensionsHosting=$(TemplatePackageVersion_MicrosoftExtensionsHosting); TemplatePackageVersion_MicrosoftExtensionsServiceDiscovery=$(TemplatePackageVersion_MicrosoftExtensionsServiceDiscovery); TemplatePackageVersion_MicrosoftSemanticKernel=$(TemplatePackageVersion_MicrosoftSemanticKernel); TemplatePackageVersion_MicrosoftSemanticKernel_Preview=$(TemplatePackageVersion_MicrosoftSemanticKernel_Preview); + TemplatePackageVersion_ModelContextProtocol=$(TemplatePackageVersion_ModelContextProtocol); TemplatePackageVersion_OllamaSharp=$(TemplatePackageVersion_OllamaSharp); TemplatePackageVersion_OpenTelemetry=$(TemplatePackageVersion_OpenTelemetry); TemplatePackageVersion_PdfPig=$(TemplatePackageVersion_PdfPig); @@ -100,6 +105,9 @@ + <_GeneratedContentEnablingJustBuiltPackages diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 2827734c794..7784747028e 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -61,6 +61,16 @@ **\NuGet.config; **\Directory.Build.targets; **\Directory.Build.props;" /> + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json new file mode 100644 index 00000000000..d4b9d0edf5b --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json @@ -0,0 +1,20 @@ +{ + "description": "", + "name": "io.github./", + "packages": [ + { + "registry_name": "nuget", + "name": "", + "version": "0.1.0-beta", + "package_arguments": [], + "environment_variables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + }, + "version_detail": { + "version": "0.1.0-beta" + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json new file mode 100644 index 00000000000..5be51dd6357 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/dotnetcli.host", + "symbolInfo": {}, + "usageExamples": [ + "" + ] +} \ No newline at end of file diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json new file mode 100644 index 00000000000..5edf447bbd4 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/ide.host", + "order": 0, + "icon": "ide/icon.ico", + "symbolInfo": [] +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..954709ffd6b9d360fbc0b121a63a29657e0fe768 GIT binary patch literal 38045 zcmeHw2Rzo@`~T;@Z3&f9vPy&OkzGher6|b`?M^i zV@yX+B6rEbjr-L5RnFBEC9E)Jw87-%kE{2ta_(i7l5u%OcG%L0w-XH6PA!zwwPG(T zHsU?2FVlUE&{2O`f8Hbu+J5OR+I|A3&n8G8m3_IsTi>XtZIhBqj>}fLAKNt5(TG?6 zE%!PgkRPy;~cexm9i$*P8P7!=!-Bo!NezYJO20X`I&w@V6yBjDOhNE9RnqUQM2L(1+Ld zJBHHDsrv>>ujkn3`xb^sxCbvgIWA_s(^4_ENs{+%%?F1yd~nk*dsLGh^>D-F`Iii@ z(+-74#*4~oPOOcN9@(@pD$iWs-O)TaE&qw_b1lo=hu^(+TT@JVUG2YOy-sz(Ax?`W zV<%nnEZ|zY=*|gYe(6h=qU!J0zAMvOKhxVwes^@6)8MVm&9gLL%#74%8B?*h8}Il? z`q+CpZ|?*!!WT={PctnYqBcuZ?3_!jL`s9%l$F9-s~^#GVsG`Ed~d(9nn~N;aUarm z51UdJ;^UvAb2Cvyq9mk@b|(3VkK`;Uv1eUniHC%bB9LJD++E(R0sNR zYLOirX}qfN=1?(nT3RBN)4Ht9EmnC=vwOC&cFsAqulDirU~M(dON$1YwOv2wC~~>bx3-sm0d+Op*#7dN6s~&VCwU@kF0X#d z6WT{_sP@DtkAp4rgE_sqg~vaT2wv*>%zSY5EuCta{uaJV7J4sSMOhaJJ)Ykzd2ISr z+sZU$ClB3yUNW1G4y}FPI{&?xoOP0wnqjUsBkX?0GS_g;a&OgsY>o-NZd`bB(e0Ml z%)F+aYd1Yxvtftt0w3Fo4fi<5-*b&xloCGE_4xzk5xESZ7whus4)4hK;w+cnH8+U+ zzVtKAGv=1>c8kQGu!&eUrcrHE^{e&ff_2SP4k;Bnysq&cx-2l~cCRWmBj2Hgx-YWI zw+&gaaJc%e**fwg&sPtcwBLW-wXqr#<$7K85u3SUXrC78i@De3lZtu`5t^~m)6BlA zbXC^G10GUo3ghn;=ISW!^Ek2l%!3D#&*EAX%w)2+HPDI=&TW>sGxS-^tVJtSO*Q28 zaIeAjH(R;NJ{ma8RM_MQ4 zPg_6do&e>`#-r-^c3%YFNq4&~I~HAdBpLCJO`oU0{oU4Twj8fm?Q+>?9%uC9D!BO9 zuB(Vy9&Q(Rc+}zpIe|xfuEy@uPgj%+w#%>+wh^$W>kVj;X>i_b__`)4GCk3>R{EJA zHMwP^e(l{4o-Y>MRwZ+_H2=KhMVhPLEn&E2 z)^CuUUg$I-^3||-Gbx2AJ~rrB)?2a~D;}geQCuzcSzGm`eTJs#OKb zh843W%Wl0_I6(j81T}m1Jw2-mH^7;lW_p+?yGc7=w3Kw& zb?dUS{DP4iyx?Ysc}y% zx^P$4po0ZL9Q>iT*op_;JawsT-Lt)w>WQJxGG7ZVi%3q^c{1YsuoKJIG|syh`u=V| z3B3%Zdq)}PbL7ik3_2LeWg+=NP03B!LM2*##tQcn?$)}`8`m~%dvi2r;C6$(&8rh? zHO4KnvaDys#T{B2uOTj)I4yd5sKtS}Ak%|WT2mLC^zfL~VzXVodycn8lK9?+m_WV= zt-(jF8MF|kYdZv0S{b_XaSgNcbcei-ySP6t!Zd1XYbx9F9G_E1WVV%!%SiX>y?%x8 zIz3r=>5E5g8R8*s!IrQ07L~ta@0XH!=ZMPm#u-twT2sBYYrYzw%Wyh%y~$O3l1;7Q zg7<8d51j%8ZfWc=nWkIf;k0$hkriuml12q4)z=uGI-Q-bO>Z`cdZ^}ARryG}_fdOB zpO8g%1}k?ZGxmioXS|e(*{rO}7oSwm;5lR;;yPPoz#xyo8;l29UvAr2w@R76SDIy! zuv=@y1X|4HM46Bm@^`(*&kc0A>^?Y>bBErEJ5AhiC-s8tLZh!duXy$P_3QHUN%pHI z^|!OkEmswKsS!QZ-Ha*}`yAw#uhg)J@SR*A(7Q^kCOp%o!T+eq#Q9Gb`Q$5mrWIAbZ!6l6 zR8!#?`e9wpq(q-%%}0j6_g$5LvGwfTdY&1NcGPdzJfB`NJ2>_@z&oOM#dq9o-o>$H zBhvc}3~_OKSURmRLbH63#ceP4$GQ8AEqdCS>5S{ijlK44|>VDpQ^~q)vuAdEfVA-W4dg1ioANc zRCZ*W{l%)cW2`fT^aM{BB&}!~u}gfoV|Z|p#kups>T$;zqt4kc5;k2XQ0m1#Lig@t z35)wns0(f7r$r9!wn$ac*s8zGU;q9H(URufSqgi4h&x9FJCA-Ko;Acsj%!_&0LRI? z9v-#w?q-`WOy{}KmaQ-OY|9$6{9OHUB945b{-?|Q+*4W63_#Pf| zv^)vBN99tc4GZ1nwuHEhxzJ{@r0*cJ#hI6eXNQUR@$J>)+BUb=NrE?Q7Wv##_8jB% z#&~h_wz8nHs#zg|y(T`NBb&?Bw?aE}V!%Lpo?_Z;O&8ll&4l9%7xE6apvND$|M-c( z?Znax7Q@b!*PMCae(qHx+ri^)L$reW<;5Ot>-}g(O7go1r|YKmx0i{lFebV`yGsj@ z4oen%RUA2g@6CFX6K$u;7e~uj2W}rLpmmqGW?YP(gktaTne(<++Q&YPo9y6g39-1> ztFGIj!n%EHGO8<7rVHB4In^kg7dx_5Qta8(`ox(VM+ej|zpFiJmgrS=HIqHp>ra?N zCcd_RVI$q6tnUr?Be!Ncs|5DisPtfSRj^8HlzN>0_N1LPY$3LC&(h`RMm_ScvOee& zqMukInQ5~(+bAG3poh8lXq~)RgMlZiAfgL^;_IA=s zq#8utZHf`cg}pu&8(dG74%ob0n082bT%P)YJj%gWy1xIMsH(ebCO;EcEyr<|L0{y5 z|1uS$x4qe*A|;%^!nan@Wuxb=SKfYHY4jK;@8(l+H&)n{PpO@uJEKN!$2-Gw|e&pd6js(Ah;^nsPzcNrx?~hC@J{^ZT>DNp5o>m*#dGI zL$dVq8))t)_{ZH@c(eY*WIYSN^+8KjT-oEJMy912#OS?$S3~1jcA;cON&JCCBNwke zH|k}hC2ZCW=PTWsP-k{VpsabiIYg0C+F(72E1Qm+%h@Nk+2s26J7n(=l2$v@Vyd!5 zR((d8PZ7t0_`q3>>e(f$9xoQilbGKuJ8^bO>}2OnyY1r(&jwZn%eN}hmz!POaLkG~ zO=%CsedtMFrHeD%GF~r^E%G;CI-6_~mTxxXk*j z9ewU!_6S@tV0z0C1IcF>*G!-VJv{u9OZ?i2fQCL7e2z^!RQ$kyp;7UD0jj1n?(~fn z@wt9MD_2SI+WBhv-Amidrz4%cE9aDhuV2qA;fAus@lO>W>oN-F?rupCx7AX%X-##| zb*)NwYn3q1FT1-t|LyK70o4;{3>h2uBrkohlOOYN)Uobjx`xLOZ;72fd}f~yZyFsZ z&fnm8>wU`JEcKT%c@jOI)`o{gUn;Rl@bJEO>3wyWUFp3cqw|BhU3K2s60@{+yrT0H zgBT~Pr<*rchl?jVnnh*C*sU(BDL#j2#R^GtVe7udON z?lk4o6V*lXVztAj1$su=FVa}`;k(*@rVXY(<}prza_LzQX(~L~k&%6(W2i9a@vouowEJyLK52`q zHxnK!OF6GAEM=?nbC=4Cov4rNkUhW}>0|5e3=5`N5zkYymuv z*q%>2JAADS4UZqv+hAu|ra*so>7*muNxgomCms9s^s5S`iZ-!<-AoHtpY* z*d|@hbMpNAojo1}i3`{8pKjBTblEnp*<3PDBJI^xmnzM0cGIY>Y5InK+@)^&uF1Vo z+gcSo?~c-lZZ~4z+K%KAbQ#`!HzZG@{1JY$=8$D(fuxaTR45~`H`7k!8I=gZ&++wQ8y(lU;RQG_gTIt z?#aqQJqxbtHQos22)sJmz_m=(q0f{)e0n`=UNj#Ju%goaGXnw-OC{GByf`1#Or!U^ zsUobI{Nd4T?c!#s8@vnRyPulns(odj<2j3#Ub&|yv#WST#E%lS4Hdn9=5D=-5WS+D zOVsaeW%McEIpg;?UT8f!*ERJ5m)o7Vrz1^nojB8&|G=%4w{~QT@N4(8j=XJEGo0tW zth~MSwvdyj+yot$rh7JAlw`=8qh-h9>Vuj}xUCgk#~Ql5jX&me#DimO_MP)h!M$zU zB=d5mICwn69#5!W<+w)Ch|3{r<^Idp_If;i=lynt8mD8Zn%NWe*;C>Z^m20)OMITW z4sDtFU@QMg?e2{a!avLke8xHH;=O}B^oobi8+fPPiE?_+Eib*g=TQ!hRoCaYz6|a; zm8;^g&>=3*!Ge`n>t%1wo+A|LZkOm&-LvKXRtMjJ{gZj!S|=I~P8qH@HKO0d)(Qt- zwKu)z$#+ZRGhY%@p6?%YGBOc{ns7jTX|q)7ypTmcKIeCODx5jIbGgW?y?f1*1_uns z9ro4lbli=t;#~QTwDAWfOu680>Xcz48>4sN;^qeV+_cw`weQ##-@m46>2>$*kR{$1 z+BTi*cci{*iTlmR9QBerqYr76Tb;~&Q>CGrx4z1HQ(B{pjE0U#SK-or;T6k59Ccz20gtVR0Jx)BjC`(ool&JCOz3 zHxzxKj|dleRgkIkBH)-(sIsO!*wcb-Uq$|+-129Ym8IhsISy$)Aol7-F%o+mjs>wJbksAn{ZZa)0%u=f~4(TsJm719btx(Wy7?XGNj zog`N{)w-dX?t9Z#P0h0*?!dk!R&n>5me5n{=ZnNR<-#%8P?e|VaQSd?^YHF@v2!o@ zjKB7kTT54aG`P3gm8qB}$S|5E(+3qdPv0VZIxWolwl+gEPh$Ej8qeh=X9fDhr06R! zJ^o<-y5*xW@@K?mvu`}Xkr!(vo+e^qonE@HDp)Isc4K{*!gs?pX^{E%PA*lJ%S0(p9TmO8MyqO^%Nbw!s59b}v`I$uZpFz0dZE|Pd!1~c zM~tJKIS-3R%8O==%(&pS;D{e*FK3;RSB}P3sUBZ-`c7Y=Co)lE*6cg1@-UV|jmw;N z`7h9rhNhZx58>dO!$sYH*=0PAaC`0dFw!a>rOmHNPOoE5~t4(w057lXzmweD0NRW@xmYYDtjDAGy^-czev$ zO+}@WV@``qX|340L-lfHUHr&1YiZ4@c@Gb3xjqS5BGhIx_kvA+@abY>*<8C^lQB)x zOW)*)B|PBQ_U9~^!-`Y$n_OyP%NKd9^d=y5Jp$;|7^4ZJV@5fzk;ZR*9DX=R_?=nw zDXLeukH^Og|M$16P+ zd-v`NVEz;szJ@!&AGD|&Hf)$-zkdCGO5gu>qu;`vjg74twLLJUU_!+=YaMwu~Azs+)?6N~ibzZ#Nhl8_NViMn+~97Z+C}!I^dZ*KsF^ zTU%RG4h{~zP_kHfllp%FcY^gt*i zyrZI`NcEpA3l}b=9zJ}?*1v!Mo#6X+*#~49;En$Y8pzAbBaKRb5I;YE8jVK#pQ8bE zKh>>UHwyZH7#|;B`v1}3|7h@E;e+VtXeuo&jW%)OM1c__MhumdlvI(BkWhgiEA8m$ zD0u$-d0Iq7M7wPNugnD}Cnw6y&5aX2t5)B>eFGrdGI@A-Ucis7AqVu`OUU;u`1OI% zf4b||t>cgE|?POv~N(yuQ;G=5x?Ai0?=lYz5^OtJ!EO5UGn^%)GLfZPBXmIS&ORn-x{dCkHBbDc40(4f@}27}tXc{3AF#P9{- z?ERep_2x=J-P9xpt%v?w5cUnEw5L zfd&|FzmE9B)=QW$VFKsq(W9C8!|q+&5r14{{Fi6|`)y@Mdk6CD_PBB5dXRNI$Uq9V zza;G4>m6zIU!eh!??Z(st|`zZ1Z;`hT~8~@3_@5+2IckWyY z2Rmf;K-jesu+7<6ehQPZ@drAbi5qY(Cu2$uGS0C7!?$nW&M|fB)ZdZ@^XAQ?wr<_Z z0lRG___-8E0p_vW?Afz9z}qbJsVP&YP^(t0;)LCMjD?Tg@QjI20l3V zZ_@y>i^ANcU~3w}9%)}+BRl}#a|hvnA0MCpG!2N4m7Sf<2K#hxN81Cuk!50H(gVkL z@h1=bPJ948bYDkDM*;Gfi5rQ>f!{y3PhNm8q=|p>qvdy^fryC67J`?*zdr?eMAn%# zF@M`*Ekp*8STyGKkD||f?suR8;d9vIfh_zXzlnW1lE^DuW7=o1O=_S6Rgh!$m+&VK zcSRb&2Gxc<&jenz;Qw^+zap`{_w3n2fgWW1XLZ!ekc-a^3=9Tg-SIEu-_>Z4nwrYA zWyXvdBMg0~1ew-{zs{zNZBCNKj}8#ZkC(yKpw?cb&Wd~1u$%uM>?#f$#{v$L}^6&Dvr zM{bP;?AKBj?xaS%^@X;!wkk_M{NcsFaq@4{fY>!yPxJ>q$|T-|oMBG*WBeK#8f2X@ z2;<&v_YmBP41jFh0NaO7{JYL7|0WFxKZ6ccuyZqE*CZgGkcly``rQ5`=dk^gh@5~A z)LE#vxAUuLK=4GT?Xv7geiNMt8}J(9Q4o%-f zC;uM)(EIhkeIJS65L}->f8LoW|EaB?NdscfH#Ie}!M@u=#uS~mAFOjyW1hER?pMJk zh=ZMDIeGGA9>|D4!TPf{E2=8O?Kf~AB?p( zVpdMbom3)t!}ce-;Af%w)pk3R2C(T)A(w<5u{R3xo7i*e;Oq8S6KGKbyj39Iz6#I3 zaicS6K=>K{dKA%FBsYTKk1^E&{&m2KIsW+lxBQu3L6g5j1ITGH*k}h}rx#%kCu8oa zAf`fHy?T`iGW?d$?fEk3w83Pptt*zlLIVOnSu2O_&I3P24C|)di4TA9;6ZBEtXU*x z(+#}-xjmmYW5x`AvX7#xmcL4a&+%ZbC4R}C$NXU0^D9@b{QR{pb^bjXfag~NPd;L! zbc|0Eeh3Tw4jh+Hxe*gOWH zO$3Qs0LONnPwaWba5fRy_#?5*u0o%0(SXQ%$nz%1zGCS0JE$L{e#f$JSojlr9)5ii z*5-sU{#^yNABKbEOkr)X7yPt6824rtUO!S3+#v(6n3h_pb5Uf&~kx-Me?w5sOoU4I2V^ z_5ky|p4jjNUoyur9~y}*0Dg~#oHIpihx12c_gw?a??bU{*)oz7N5?u^U&uRs$oi?U z^`|3#>jIr;f_$O@*e}BIU*7}#eM1@C~cqW7TU-27PgOd zBiE-|;8QK}+4-mYtmoT4z0dg69>dRS~T9E{)Q>j{JgMQR4yB8gfq$_))jG_v_=q5**xS}NMt6lkN0(154_gK|Ry zsQon20P0jxG=RDY4Tx$AFqjQAN-#(RY`};#Kv5EaA5TyKlr%sAaMA!l1P%BR#L#{! zK^P6Bq9P42>lWG-X5B`W`jP7l%6B!h_8a+;psCcyx`_HXFtrqE1`SXj2c`wRGYjdJ zRmgWD$f}t$4jSt_5rh7n??eD5is&}%e`7;_HJ#*3{w`(z{{2i)5o1<`{}u&Zy%j#3 z$nUV9llWPrU|)s?{H|R5WDva!zx$6tcIH!E>kn(MB=UUPVJGlIhc-dC{{6+CUx4Px zlP8(Ibfi0h9kg%4deuhQxIFL+egPzZuL}YjbhIRR<`#jQ1v}0+!Or6)IY(W!kk~RQ z@Uh%kHYlMxVBd)G=+dwgxB(-nDEP+PQ88(cb%I0^JNX%F9n8);@ej$3BL#fEhUMo0 zmkP*#Yv=)*r>AEp_V#m{0`HJLtXhEkC3;|v0c>`HgAKuf#4fvXA?wBE<>fTQAEv<{ zZQslAJK;dqIKV4(>>c?k4qcs>kaJ35J^PzD{2ROk-;5$Q8T`+_umvW;7hMOReFxU$ zwnDeMA||hfdWev*aOWhIYZ_uk@@hstHC$Xn6L6=o?_2R zm*6Gj>5zE1U|+;MNPLjM`?Z7M1pM;xO#xGa1KEG@Z}Jk!6URPIcG$lgLG$+g0f=Y3 zL7XE6u!h6OKZ5!(#-RN^?iC{jFy`OHVaJXg%=KpQ^(gQLQyzYzZzlFTnIh+zm&^z3 zZ=gfRN)Q||KJ9B#9dW>3m|xCIM4pqpbIi|37O!KzK1N=*BFX#fnEwI0jlxtrocsTUiP@3~L}Ce; zx6IftiIrkac-q%+_!YcFa!e7^7y>`(3izQIGSCD0|D0s*lD$);K&Gr<`BX%lQU9)V`q)D7aCK8_& z_KpO`;10P@4$!+6ILw9}$wtQLdpP_T}lW2rlWmkZVJ&m@acldd?S0_KQH8a8KeM5BjCW4hd6B? z2k0bDvQ9?yEabf9mvF$kL8tK&(V2Vq?qvhd2C{S}pv^h@2)x=q6_V zHlaD>WFf}nHnDRdhjwAT-wgABk33skT>OvV?)dUQ;U(A|PvP^3BPK!Y-5&5MZli6c zZi1|t4c~_k@uhCC9q6p_?$~!{{bsfOS9BbqG5q+ukd6IF91OZi2sF=O!H>CX|2^H> zS@4r~ew3FGyCD3c2Yz1(8L2_uC2)Xv(A)Um(s{r(m&0mjwvZI~aMXURU~7Wc*c^zW#fU@I{5PQUbhr|ZR5B7Ne+hxm^EldzFUzNclr-{u-;3n|04swp`b+8$Zz@K6|Q(Xae z7VVmwo0;G0fxpHFzuFjcAOLiZgRDtLeHM1vM#S?B5Z~a1pThL#SnqbV`qr&mBrlSN zZ`E)@w(yW|aT5O%-&*bzP|?;4G)HJ~?Nhz>Q;lpRa=qpf&GG26vEs9R{r=0e>_A)5LN-BNmr!q%SGzcMn3ZOF~${kPQrDJD|9x( zd5kMGG(c*~FIA7C-lY2JJ4;YhQIWo+5~-af1^Wu1RubgqLjqQPBvMIveU1 zb~=rQx{XewR-#j+kE181qU}gZ9zjHyuKYp8*3|e`iE=;H|)UySo5(0)AlUIzR=0e-5%s#fPwif-_C}C#%;g=CCj;{}m(#81dx+L;Xv4V?l5i5WnjaA7S;6n!(-#z=qYcRMQbJ&NPd zb6kkA3Sk~F=X+py`3bQ^$q@{qs)|Hg&%O=K6VAYMbE zzc0c7AM~HV@Dg%?85;o&h$Beye>RhTJLY$g^Uj0;vP%U090yN5X7!Kr{pg0wr;VgP ze0PUbe<+;=1NH*YF`xJ$BSZ$f!FOZmVcmsM<6-_2JrJTjJY`EW)Ii~ zBO$+2&LJ00a4kya{O+eMO)mw2r~t zH3rT~@J+grdx?pOr20$cTQI;DBy)2I!5K1T1Zfz29)*g?Z6=?SJ?a0nd<_QZ4Nk7j6=qh|dc-G8_2bM87qFr5-SpW9(0& zZ*9Wo&{bb}yK^p)Zwz0$bcy7j^akB^pi_)61`2?Shm3h)Vc`#T#9)xr3xrbxUbq;3+!BmbZU&=B5Zlzij*V!K>^~l-VEJ3n z-8#@Ku8^%9Bq!%b3ZWt9DDkD_!7~{cOK;>?(SMX@iGM;0-u1?j0=q&GcHxh7&3p#m z2&TX@L|$k>PTv9i9?uowjFAs;7=mZXc#-c;8{v2M7upyNSaU!Z zrf)`4rwG-_rQQdmT?Yw4sT + + + net10.0 + Exe + enable + enable + + + true + McpServer + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs new file mode 100644 index 00000000000..f320c93fd88 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md new file mode 100644 index 00000000000..dc6f5038b61 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md @@ -0,0 +1,82 @@ +# MCP Server + +This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Using the MCP Server in VS Code + +Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. + +```json +{ + "mcp": { + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "tool", + "exec", + "", + "--version", + "", + "--yes" + ] + } + } + } +} +``` + +Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `McpServer-CSharp` MCP server and show you the results. + +## Developing locally in VS Code + +To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: + +```json +{ + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +Alternatively, you can configure your VS Code user settings to use your local project: + +```json +{ + "mcp": { + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } + } +} +``` diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..4542f8505a5 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool(Name = "get_random_number")] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs new file mode 100644 index 00000000000..a3f3dedd1b5 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Templates.Tests; +using Microsoft.Extensions.Logging; +using Microsoft.TemplateEngine.Authoring.TemplateVerifier; +using Microsoft.TemplateEngine.TestHelper; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.AI.Templates.Tests; + +public class McpServerSnapshotTests +{ + // Keep the exclude patterns below in sync with those in Microsoft.Extensions.AI.Templates.csproj. + private static readonly string[] _verificationExcludePatterns = [ + "**/bin/**", + "**/obj/**", + "**/.vs/**", + "**/*.sln", + "**/*.in", + ]; + + private readonly ILogger _log; + + public McpServerSnapshotTests(ITestOutputHelper log) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + _log = new XunitLoggerProvider(log).CreateLogger("TestRun"); +#pragma warning restore CA2000 // Dispose objects before losing scope + } + + [Fact] + public async Task BasicTest() + { + await TestTemplateCoreAsync(scenarioName: "Basic"); + } + + private async Task TestTemplateCoreAsync(string scenarioName, IEnumerable? templateArgs = null) + { + string workingDir = TestUtils.CreateTemporaryFolder(); + string templateShortName = "mcpserver"; + + // Get the template location + string templateLocation = Path.Combine(WellKnownPaths.TemplateFeedLocation, "Microsoft.Extensions.AI.Templates", "src", "McpServer"); + + var verificationExcludePatterns = Path.DirectorySeparatorChar is '/' + ? _verificationExcludePatterns + : _verificationExcludePatterns.Select(p => p.Replace('/', Path.DirectorySeparatorChar)).ToArray(); + + TemplateVerifierOptions options = new TemplateVerifierOptions(templateName: templateShortName) + { + TemplatePath = templateLocation, + TemplateSpecificArgs = templateArgs, + SnapshotsDirectory = "Snapshots", + OutputDirectory = workingDir, + DoNotPrependCallerMethodNameToScenarioName = true, + DoNotAppendTemplateArgsToScenarioName = true, + ScenarioName = scenarioName, + VerificationExcludePatterns = verificationExcludePatterns, + } + .WithCustomScrubbers( + ScrubbersDefinition.Empty.AddScrubber((path, content) => + { + string filePath = path.UnixifyDirSeparators(); + + if (filePath.EndsWith(".csproj")) + { + // Scrub references to just-built packages and remove the suffix, if it exists. + // This allows the snapshots to remain the same regardless of where the repo is built (e.g., locally, public CI, internal CI). + var pattern = @"(?<=)"; + content.ScrubByRegex(pattern, replacement: "$1"); + } + })); + + VerificationEngine engine = new VerificationEngine(_log); + await engine.Execute(options); + +#pragma warning disable CA1031 // Do not catch general exception types + try + { + Directory.Delete(workingDir, recursive: true); + } + catch + { + /* don't care */ + } +#pragma warning restore CA1031 // Do not catch general exception types + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json new file mode 100644 index 00000000000..ab997541e52 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json @@ -0,0 +1,20 @@ +{ + "description": "", + "name": "io.github./", + "packages": [ + { + "registry_name": "nuget", + "name": "", + "version": "0.1.0-beta", + "package_arguments": [], + "environment_variables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + }, + "version_detail": { + "version": "0.1.0-beta" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs new file mode 100644 index 00000000000..73b72d35a46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md new file mode 100644 index 00000000000..25704e5d135 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md @@ -0,0 +1,82 @@ +# MCP Server + +This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Using the MCP Server in VS Code + +Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. + +```json +{ + "mcp": { + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "tool", + "exec", + "", + "--version", + "", + "--yes" + ] + } + } + } +} +``` + +Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Developing locally in VS Code + +To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +Alternatively, you can configure your VS Code user settings to use your local project: + +```json +{ + "mcp": { + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } + } +} +``` diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..72af767e320 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool(Name = "get_random_number")] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj new file mode 100644 index 00000000000..a71ac148e6f --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + Exe + enable + enable + + + true + McpServer + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + From 9344962e3acf2c690c4b43a1a3a56351365296a0 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 2 Jul 2025 13:27:21 -0400 Subject: [PATCH 06/71] Add FunctionInvokingChatClient.FunctionInvoker delegate (#6564) We've had a bunch of requests to be able to customize how function invocation is handled, and while it's already possible today by deriving from FunctionInvokingChatClient and overriding its InvokeFunctionAsync, there's a lot of ceremony involved in that. By having a property on the client instance, that behavior can instead be configured as part of a UseFunctionInvocation call. --- .../FunctionInvokingChatClient.cs | 13 +++- .../Microsoft.Extensions.AI.json | 4 + .../FunctionInvokingChatClientTests.cs | 73 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 293ff1d98f1..6b1d3b3e905 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -205,6 +205,15 @@ public int MaximumConsecutiveErrorsPerRequest set => _maximumConsecutiveErrorsPerRequest = Throw.IfLessThan(value, 0); } + /// Gets or sets a delegate used to invoke instances. + /// + /// By default, the protected method is called for each to be invoked, + /// invoking the instance and returning its result. If this delegate is set to a non- value, + /// will replace its normal invocation with a call to this delegate, enabling + /// this delegate to assume all invocation handling of the function. + /// + public Func>? FunctionInvoker { get; set; } + /// public override async Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) @@ -872,7 +881,9 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul { _ = Throw.IfNull(context); - return context.Function.InvokeAsync(context.Arguments, cancellationToken); + return FunctionInvoker is { } invoker ? + invoker(context, cancellationToken) : + context.Function.InvokeAsync(context.Arguments, cancellationToken); } private static TimeSpan GetElapsedTime(long startingTimestamp) => diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json index 4f4317c9978..59ed3d32fab 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json @@ -527,6 +527,10 @@ "Member": "System.IServiceProvider? Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationServices { get; }", "Stage": "Stable" }, + { + "Member": "System.Func>? Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvoker { get; set; }", + "Stage": "Stable" + }, { "Member": "bool Microsoft.Extensions.AI.FunctionInvokingChatClient.IncludeDetailedErrors { get; set; }", "Stage": "Stable" diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 26554946dca..1379cef8bf0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -37,6 +38,35 @@ public void Ctor_HasExpectedDefaults() Assert.False(client.IncludeDetailedErrors); Assert.Equal(10, client.MaximumIterationsPerRequest); Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); + Assert.Null(client.FunctionInvoker); + } + + [Fact] + public void Properties_Roundtrip() + { + using TestChatClient innerClient = new(); + using FunctionInvokingChatClient client = new(innerClient); + + Assert.False(client.AllowConcurrentInvocation); + client.AllowConcurrentInvocation = true; + Assert.True(client.AllowConcurrentInvocation); + + Assert.False(client.IncludeDetailedErrors); + client.IncludeDetailedErrors = true; + Assert.True(client.IncludeDetailedErrors); + + Assert.Equal(10, client.MaximumIterationsPerRequest); + client.MaximumIterationsPerRequest = 5; + Assert.Equal(5, client.MaximumIterationsPerRequest); + + Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); + client.MaximumConsecutiveErrorsPerRequest = 1; + Assert.Equal(1, client.MaximumConsecutiveErrorsPerRequest); + + Assert.Null(client.FunctionInvoker); + Func> invoker = (ctx, ct) => new ValueTask("test"); + client.FunctionInvoker = invoker; + Assert.Same(invoker, client.FunctionInvoker); } [Fact] @@ -208,6 +238,49 @@ public async Task ConcurrentInvocationOfParallelCallsDisabledByDefaultAsync() await InvokeAndAssertStreamingAsync(options, plan); } + [Fact] + public async Task FunctionInvokerDelegateOverridesHandlingAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ] + }; + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1 from delegate")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42 from delegate")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) + { + FunctionInvoker = async (ctx, cancellationToken) => + { + Assert.NotNull(ctx); + var result = await ctx.Function.InvokeAsync(ctx.Arguments, cancellationToken); + return result is JsonElement e ? + JsonSerializer.SerializeToElement($"{e.GetString()} from delegate", AIJsonUtilities.DefaultOptions) : + result; + } + }); + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); + } + [Fact] public async Task ContinuesWithSuccessfulCallsUntilMaximumIterations() { From 0ba068956a6ac3cf85c887db3e5bbe085d94bbf6 Mon Sep 17 00:00:00 2001 From: Joel Verhagen Date: Wed, 2 Jul 2025 13:28:50 -0400 Subject: [PATCH 07/71] Use dnx instead of dotnet tool exec in template README (#6571) --- .../src/McpServer/McpServer-CSharp/README.md | 4 +--- .../Snapshots/mcpserver.Basic.verified/mcpserver/README.md | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md index dc6f5038b61..50091888ad8 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md @@ -24,10 +24,8 @@ Once the MCP server package is published to NuGet.org, you can use the following "servers": { "McpServer-CSharp": { "type": "stdio", - "command": "dotnet", + "command": "dnx", "args": [ - "tool", - "exec", "", "--version", "", diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md index 25704e5d135..5c00a3bf669 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md @@ -24,10 +24,8 @@ Once the MCP server package is published to NuGet.org, you can use the following "servers": { "mcpserver": { "type": "stdio", - "command": "dotnet", + "command": "dnx", "args": [ - "tool", - "exec", "", "--version", "", From 6e2f719cb11ad82593f451a73168edd53b4a523d Mon Sep 17 00:00:00 2001 From: Peter Waldschmidt Date: Wed, 2 Jul 2025 17:15:40 -0400 Subject: [PATCH 08/71] Add reporting tests that show NLP results. (#6574) * Add reporting tests that show NLP results. * Cleanup analyzer errors. * Add global tags for NLP * Add more precision to the evaluator timing * More tags * Add another partial match test --- .../BLEUEvaluator.cs | 2 +- .../F1Evaluator.cs | 2 +- .../GLEUEvaluator.cs | 2 +- .../EvaluationMetricExtensions.cs | 2 +- ...ons.AI.Evaluation.Integration.Tests.csproj | 1 + .../NLPEvaluatorTests.cs | 162 ++++++++++++++++++ 6 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs index e1419bd630e..f3030ec7cfb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs @@ -86,7 +86,7 @@ public ValueTask EvaluateAsync( }); metric.Value = score; - string durationText = $"{duration.TotalSeconds.ToString("F2", CultureInfo.InvariantCulture)} s"; + string durationText = $"{duration.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); metric.AddOrUpdateContext(context); metric.Interpretation = metric.Interpret(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs index b0806be6d66..e070577c448 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs @@ -77,7 +77,7 @@ public ValueTask EvaluateAsync( }); metric.Value = score; - string durationText = $"{duration.TotalSeconds.ToString("F2", CultureInfo.InvariantCulture)} s"; + string durationText = $"{duration.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); metric.AddOrUpdateContext(context); metric.Interpretation = metric.Interpret(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs index 0c9805ee108..60df30879a4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs @@ -86,7 +86,7 @@ public ValueTask EvaluateAsync( }); metric.Value = score; - string durationText = $"{duration.TotalSeconds.ToString("F2", CultureInfo.InvariantCulture)} s"; + string durationText = $"{duration.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); metric.AddOrUpdateContext(context); metric.Interpretation = metric.Interpret(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs index d3012030cec..534f5e300f7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs @@ -177,7 +177,7 @@ public static void AddOrUpdateChatMetadata( if (duration is not null) { - string durationText = $"{duration.Value.TotalSeconds.ToString("F2", CultureInfo.InvariantCulture)} s"; + string durationText = $"{duration.Value.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj index c08667ff421..6e3332ebca6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs new file mode 100644 index 00000000000..a4f3b75045a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs @@ -0,0 +1,162 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods that take it. +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.NLP; +using Microsoft.Extensions.AI.Evaluation.Reporting; +using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Integration.Tests; + +[Experimental("AIEVAL001")] +public class NLPEvaluatorTests +{ + private static readonly ReportingConfiguration? _nlpReportingConfiguration; + + static NLPEvaluatorTests() + { + if (Settings.Current.Configured) + { + string version = $"Product Version: {Constants.Version}"; + string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; + string projectName = $"Project: Integration Tests"; + string testClass = $"Test Class: {nameof(NLPEvaluatorTests)}"; + string usesContext = $"Feature: Context"; + + IEvaluator bleuEvaluator = new BLEUEvaluator(); + IEvaluator gleuEvaluator = new GLEUEvaluator(); + IEvaluator f1Evaluator = new F1Evaluator(); + + _nlpReportingConfiguration = + DiskBasedReportingConfiguration.Create( + storageRootPath: Settings.Current.StorageRootPath, + evaluators: [bleuEvaluator, gleuEvaluator, f1Evaluator], + executionName: Constants.Version, + tags: [version, date, projectName, testClass, usesContext]); + } + } + + [ConditionalFact] + public async Task ExactMatch() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(ExactMatch)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + EvaluationResult result = await scenarioRun.EvaluateAsync(referenceText, [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task PartialMatch() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(PartialMatch)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + var similarText = "The brown fox quickly jumps over a lazy dog."; + EvaluationResult result = await scenarioRun.EvaluateAsync(similarText, [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task Unmatched() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(Unmatched)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + EvaluationResult result = await scenarioRun.EvaluateAsync("What is life's meaning?", [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task AdditionalContextIsNotPassed() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(AdditionalContextIsNotPassed)}"); + + EvaluationResult result = await scenarioRun.EvaluateAsync("What is the meaning of life?"); + + Assert.True( + result.Metrics.Values.All(m => m.ContainsDiagnostics(d => d.Severity is EvaluationDiagnosticSeverity.Error)), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? bleu)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? gleu)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? f1)); + + Assert.Null(bleu.Context); + Assert.Null(gleu.Context); + Assert.Null(f1.Context); + + } + + [MemberNotNull(nameof(_nlpReportingConfiguration))] + private static void SkipIfNotConfigured() + { + if (!Settings.Current.Configured) + { + throw new SkipTestException("Test is not configured"); + } + + Assert.NotNull(_nlpReportingConfiguration); + } +} From a71873af3643fd0bede1201d2076b175d417315f Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 2 Jul 2025 15:55:40 -0700 Subject: [PATCH 09/71] Branding updates for 9.8.0 (#6573) * Branding updates for 9.8.0 * Remove unused suppressions --- eng/Versions.props | 4 +- .../CompatibilitySuppressions.xml | 46 ------------------- .../aichatweb.ServiceDefaults.csproj | 2 +- .../aichatweb.Web/aichatweb.Web.csproj | 4 +- .../aichatweb/aichatweb.csproj | 4 +- .../aichatweb.ServiceDefaults.csproj | 2 +- .../aichatweb.Web/aichatweb.Web.csproj | 4 +- .../aichatweb.ServiceDefaults.csproj | 2 +- .../aichatweb.Web/aichatweb.Web.csproj | 2 +- .../aichatweb/aichatweb.csproj | 4 +- 10 files changed, 14 insertions(+), 60 deletions(-) delete mode 100644 src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/CompatibilitySuppressions.xml diff --git a/eng/Versions.props b/eng/Versions.props index 03fa0e24fd4..5786b4478ec 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -1,12 +1,12 @@ 9 - 7 + 8 0 preview 1 $(MajorVersion).$(MinorVersion).$(PatchVersion) - 9.5.0 + 9.6.0 $(MajorVersion).$(MinorVersion).0.0 - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_EnableDiskIoMetrics - lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_EnableDiskIoMetrics(System.Boolean) - lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_EnableDiskIoMetrics - lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_EnableDiskIoMetrics(System.Boolean) - lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_EnableDiskIoMetrics - lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_EnableDiskIoMetrics(System.Boolean) - lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - \ No newline at end of file diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index 8f588a6bef4..b54a7690839 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index e76ef5b53f6..e3e384f86da 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj index a00ffe716f9..fd2138900b0 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index 8f588a6bef4..b54a7690839 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index 13d362a5b15..9a1b1da5279 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index 8f588a6bef4..b54a7690839 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index a69db54eabc..74fed505bde 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj index 01d4728712a..66360fa60a0 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj @@ -8,9 +8,9 @@ - + - + From 41df288e494b2a27e8469eee3bfd58d8d86abd3e Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 2 Jul 2025 23:53:52 -0400 Subject: [PATCH 10/71] Enable specifying "strict" for OpenAI clients via ChatOptions (#6552) * Enable specifying "strict" for OpenAI clients via ChatOptions * Address PR feedback --- .../OpenAIAssistantChatClient.cs | 13 +-- .../OpenAIChatClient.cs | 19 ++-- .../OpenAIClientExtensions.cs | 19 ++-- .../OpenAIRealtimeConversationClient.cs | 9 +- .../OpenAIResponseChatClient.cs | 19 ++-- .../OpenAIChatClientTests.cs | 86 +++++++++++++++---- .../OpenAIResponseClientTests.cs | 75 ++++++++++++++++ 7 files changed, 195 insertions(+), 45 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs index 3547483da10..0b6c5f5122f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs @@ -237,14 +237,16 @@ void IDisposable.Dispose() } /// Converts an Extensions function to an OpenAI assistants function tool. - internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction) + internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null) { - (BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); return new FunctionToolDefinition(aiFunction.Name) { Description = aiFunction.Description, - Parameters = parameters, + Parameters = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), StrictParameterSchemaEnabled = strict, }; } @@ -296,7 +298,7 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( switch (tool) { case AIFunction aiFunction: - runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction)); + runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options)); break; case HostedCodeInterpreterTool: @@ -342,7 +344,8 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat( jsonFormat.SchemaName, BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription); + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options.AdditionalProperties)); break; case ChatResponseFormatJson jsonFormat: diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index abbcb0ed0ae..c051550d493 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -101,11 +101,17 @@ void IDisposable.Dispose() } /// Converts an Extensions function to an OpenAI chat tool. - internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction) + internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? options = null) { - (BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); - - return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, parameters, strict); + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); + + return ChatTool.CreateFunctionTool( + aiFunction.Name, + aiFunction.Description, + OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), + strict); } /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. @@ -517,7 +523,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { if (tool is AIFunction af) { - result.Tools.Add(ToOpenAIChatTool(af)); + result.Tools.Add(ToOpenAIChatTool(af, options)); } } @@ -555,7 +561,8 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( jsonFormat.SchemaName ?? "json_schema", BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription) : + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options.AdditionalProperties)) : OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 24fd93ccb65..b20769c0dc4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -21,6 +21,7 @@ #pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable SA1515 // Single-line comment should be preceded by blank line #pragma warning disable CA1305 // Specify IFormatProvider +#pragma warning disable S1135 // Track uses of "TODO" tags namespace Microsoft.Extensions.AI; @@ -182,15 +183,17 @@ public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); + // TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict. + + /// Gets whether the properties specify that strict schema handling is desired. + internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => + additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && + strictObj is bool strictValue ? + strictValue : null; + /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. - internal static (BinaryData Parameters, bool? Strict) ToOpenAIFunctionParameters(AIFunction aiFunction) + internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict) { - // Extract any strict setting from AdditionalProperties. - bool? strict = - aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; - // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting. JsonElement jsonSchema = strict is true ? StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) : @@ -201,7 +204,7 @@ strictObj is bool strictValue ? var tool = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext.Default.ToolJson)!; var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson)); - return (functionParameters, strict); + return functionParameters; } /// Used to create the JSON payload for an OpenAI tool description. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs index 892a9e9aa2a..abfebd99f34 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using OpenAI.RealtimeConversation; namespace Microsoft.Extensions.AI; @@ -9,14 +8,16 @@ namespace Microsoft.Extensions.AI; /// Provides helpers for interacting with OpenAI Realtime. internal sealed class OpenAIRealtimeConversationClient { - public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunction aiFunction) + public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunction aiFunction, ChatOptions? options = null) { - (BinaryData parameters, _) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); return new ConversationFunctionTool(aiFunction.Name) { Description = aiFunction.Description, - Parameters = parameters, + Parameters = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), }; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index a5f68e10365..46019166719 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -323,11 +323,17 @@ void IDisposable.Dispose() // Nothing to dispose. Implementation required for the IChatClient interface. } - internal static ResponseTool ToResponseTool(AIFunction aiFunction) + internal static ResponseTool ToResponseTool(AIFunction aiFunction, ChatOptions? options = null) { - (BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); - - return ResponseTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, parameters, strict ?? false); + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); + + return ResponseTool.CreateFunctionTool( + aiFunction.Name, + aiFunction.Description, + OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), + strict ?? false); } /// Creates a from a . @@ -380,7 +386,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt switch (tool) { case AIFunction aiFunction: - ResponseTool rtool = ToResponseTool(aiFunction); + ResponseTool rtool = ToResponseTool(aiFunction, options); result.Tools.Add(rtool); break; @@ -442,7 +448,8 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt ResponseTextFormat.CreateJsonSchemaFormat( jsonFormat.SchemaName ?? "json_schema", BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription) : + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options.AdditionalProperties)) : ResponseTextFormat.CreateJsonObjectFormat(), }; } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 30d03b6eee3..edb5d9fab07 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -276,6 +276,74 @@ public async Task BasicRequestResponse_Streaming() }, usage.Details.AdditionalCounts); } + [Fact] + public async Task ChatOptions_StrictRespected() + { + const string Input = """ + { + "tools": [ + { + "function": { + "description": "Gets the age of the specified person.", + "name": "GetPersonAge", + "strict": true, + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + } + }, + "type": "function" + } + ], + "messages": [ + { + "role": "user", + "content": "hello" + } + ], + "model": "gpt-4o-mini", + "tool_choice": "auto" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "created": 1727888631, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], + AdditionalProperties = new() + { + ["strictJsonSchema"] = true, + }, + }); + Assert.NotNull(response); + } + [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { @@ -337,7 +405,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); return openAIOptions; }, ModelId = null, @@ -416,7 +484,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); return openAIOptions; }, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. @@ -600,20 +668,6 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream Assert.Equal("Hello! How can I assist you today?", responseText); } - /// Converts an Extensions function to an OpenAI chat tool. - private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) - { - bool? strict = - aiFunction.AdditionalProperties.TryGetValue("strictJsonSchema", out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; - - // Map to an intermediate model so that redundant properties are skipped. - var tool = JsonSerializer.Deserialize(aiFunction.JsonSchema)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool)); - return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); - } - /// Used to create the JSON payload for an OpenAI chat tool description. internal sealed class ChatToolJson { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 8b27cd918a7..28125e462b7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -288,6 +288,81 @@ public async Task BasicRequestResponse_Streaming() Assert.Equal(36, usage.Details.TotalTokenCount); } + [Fact] + public async Task ChatOptions_StrictRespected() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "name": "GetPersonAge", + "description": "Gets the age of the specified person.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", + "object": "response", + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "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() + { + Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], + AdditionalProperties = new() + { + ["strictJsonSchema"] = true, + }, + }); + Assert.NotNull(response); + } + [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { From 9bc6b369cba5510a492e86df54d1f9f185d05c06 Mon Sep 17 00:00:00 2001 From: Peter Waldschmidt Date: Thu, 3 Jul 2025 01:59:20 -0400 Subject: [PATCH 11/71] Fix ConfigureEvaluationTests script when is not supplied (#6575) --- scripts/ConfigureEvaluationTests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ConfigureEvaluationTests.ps1 b/scripts/ConfigureEvaluationTests.ps1 index d58cb1352db..b00624ac29d 100644 --- a/scripts/ConfigureEvaluationTests.ps1 +++ b/scripts/ConfigureEvaluationTests.ps1 @@ -31,7 +31,7 @@ if ($Configure -and $Unconfigure) { Exit 1 } -if (!(Test-Path $ConfigRoot)) { +if (-not $ConfigRoot -or -not (Test-Path $ConfigRoot)) { $ConfigRoot = "$HOME/.config/dotnet-extensions" } From 95a6013ff8db5ff54b8bb28663a1d0f185abe584 Mon Sep 17 00:00:00 2001 From: Evgeny Fedorov <25526458+evgenyfedorov2@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:14:20 +0200 Subject: [PATCH 12/71] Refactor Resource Monitoring (#6554) --- .../CompatibilitySuppressions.xml | 89 ++++++++++ .../Linux/LinuxUtilizationProvider.cs | 156 ++++++------------ .../Linux/Log.cs | 14 +- .../ResourceMonitoringOptions.cs | 19 +-- .../ResourceUtilizationInstruments.cs | 16 -- .../Linux/AcceptanceTest.cs | 79 +-------- .../Linux/LinuxUtilizationProviderTests.cs | 2 +- .../ResourceMonitoringOptionsTests.cs | 6 +- 8 files changed, 151 insertions(+), 230 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/CompatibilitySuppressions.xml diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/CompatibilitySuppressions.xml b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/CompatibilitySuppressions.xml new file mode 100644 index 00000000000..32bf7744fbd --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/CompatibilitySuppressions.xml @@ -0,0 +1,89 @@ + + + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_UseDeltaNrPeriodsForCpuCalculation + lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_UseDeltaNrPeriodsForCpuCalculation(System.Boolean) + lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_UseDeltaNrPeriodsForCpuCalculation + lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_UseDeltaNrPeriodsForCpuCalculation(System.Boolean) + lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_UseDeltaNrPeriodsForCpuCalculation + lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_UseDeltaNrPeriodsForCpuCalculation(System.Boolean) + lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_CalculateCpuUsageWithoutHostDelta + lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_CalculateCpuUsageWithoutHostDelta(System.Boolean) + lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_CalculateCpuUsageWithoutHostDelta + lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_CalculateCpuUsageWithoutHostDelta(System.Boolean) + lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_CalculateCpuUsageWithoutHostDelta + lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + + CP0002 + M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_CalculateCpuUsageWithoutHostDelta(System.Boolean) + lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll + true + + diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs index af13b8100ba..5fb2f0a189e 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs @@ -17,23 +17,16 @@ internal sealed class LinuxUtilizationProvider : ISnapshotProvider { private const double One = 1.0; private const long Hundred = 100L; - private const double CpuLimitThreshold110Percent = 1.1; - // Meters to track CPU utilization threshold exceedances - private readonly Counter? _cpuUtilizationLimit100PercentExceededCounter; - private readonly Counter? _cpuUtilizationLimit110PercentExceededCounter; - - private readonly bool _useDeltaNrPeriods; private readonly object _cpuLocker = new(); private readonly object _memoryLocker = new(); private readonly ILogger _logger; private readonly ILinuxUtilizationParser _parser; private readonly ulong _memoryLimit; + private readonly long _cpuPeriodsInterval; private readonly TimeSpan _cpuRefreshInterval; private readonly TimeSpan _memoryRefreshInterval; private readonly TimeProvider _timeProvider; - private readonly double _scaleRelativeToCpuLimit; - private readonly double _scaleRelativeToCpuRequest; private readonly double _scaleRelativeToCpuRequestForTrackerApi; private readonly TimeSpan _retryInterval = TimeSpan.FromMinutes(5); @@ -42,18 +35,11 @@ internal sealed class LinuxUtilizationProvider : ISnapshotProvider private DateTimeOffset _refreshAfterCpu; private DateTimeOffset _refreshAfterMemory; - - // Track the actual timestamp when we read CPU values - private DateTimeOffset _lastCpuMeasurementTime; - private double _cpuPercentage = double.NaN; private double _lastCpuCoresUsed = double.NaN; private double _memoryPercentage; private long _previousCgroupCpuTime; private long _previousHostCpuTime; - private long _cpuUtilizationLimit100PercentExceeded; - private long _cpuUtilizationLimit110PercentExceeded; - private long _cpuPeriodsInterval; private long _previousCgroupCpuPeriodCounter; public SystemResources Resources { get; } @@ -66,7 +52,6 @@ public LinuxUtilizationProvider(IOptions options, ILi DateTimeOffset now = _timeProvider.GetUtcNow(); _cpuRefreshInterval = options.Value.CpuConsumptionRefreshInterval; _memoryRefreshInterval = options.Value.MemoryConsumptionRefreshInterval; - _useDeltaNrPeriods = options.Value.UseDeltaNrPeriodsForCpuCalculation; _refreshAfterCpu = now; _refreshAfterMemory = now; _memoryLimit = _parser.GetAvailableMemoryInBytes(); @@ -76,8 +61,8 @@ public LinuxUtilizationProvider(IOptions options, ILi float hostCpus = _parser.GetHostCpuCount(); float cpuLimit = _parser.GetCgroupLimitedCpus(); float cpuRequest = _parser.GetCgroupRequestCpu(); - _scaleRelativeToCpuLimit = hostCpus / cpuLimit; - _scaleRelativeToCpuRequest = hostCpus / cpuRequest; + float scaleRelativeToCpuLimit = hostCpus / cpuLimit; + float scaleRelativeToCpuRequest = hostCpus / cpuRequest; _scaleRelativeToCpuRequestForTrackerApi = hostCpus; // the division by cpuRequest is performed later on in the ResourceUtilization class #pragma warning disable CA2000 // Dispose objects before losing scope @@ -87,21 +72,15 @@ public LinuxUtilizationProvider(IOptions options, ILi var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope - if (options.Value.CalculateCpuUsageWithoutHostDelta) + if (options.Value.UseLinuxCalculationV2) { cpuLimit = _parser.GetCgroupLimitV2(); - - // Try to get the CPU request from cgroup cpuRequest = _parser.GetCgroupRequestCpuV2(); // Get Cpu periods interval from cgroup _cpuPeriodsInterval = _parser.GetCgroupPeriodsIntervalInMicroSecondsV2(); (_previousCgroupCpuTime, _previousCgroupCpuPeriodCounter) = _parser.GetCgroupCpuUsageInNanosecondsAndCpuPeriodsV2(); - // Initialize the counters - _cpuUtilizationLimit100PercentExceededCounter = meter.CreateCounter("cpu_utilization_limit_100_percent_exceeded"); - _cpuUtilizationLimit110PercentExceededCounter = meter.CreateCounter("cpu_utilization_limit_110_percent_exceeded"); - _ = meter.CreateObservableGauge( ResourceUtilizationInstruments.ContainerCpuLimitUtilization, () => GetMeasurementWithRetry(() => CpuUtilizationLimit(cpuLimit)), @@ -109,24 +88,24 @@ public LinuxUtilizationProvider(IOptions options, ILi _ = meter.CreateObservableGauge( name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, - observeValues: () => GetMeasurementWithRetry(() => CpuUtilizationWithoutHostDelta() / cpuRequest), + observeValues: () => GetMeasurementWithRetry(() => CpuUtilizationRequest(cpuRequest)), unit: "1"); } else { _ = meter.CreateObservableGauge( name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, - observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * _scaleRelativeToCpuLimit), + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * scaleRelativeToCpuLimit), unit: "1"); _ = meter.CreateObservableGauge( name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, - observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * _scaleRelativeToCpuRequest), + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * scaleRelativeToCpuRequest), unit: "1"); _ = meter.CreateObservableGauge( name: ResourceUtilizationInstruments.ProcessCpuUtilization, - observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * _scaleRelativeToCpuRequest), + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * scaleRelativeToCpuRequest), unit: "1"); } @@ -148,10 +127,9 @@ public LinuxUtilizationProvider(IOptions options, ILi _logger.SystemResourcesInfo(cpuLimit, cpuRequest, _memoryLimit, _memoryLimit); } - public double CpuUtilizationWithoutHostDelta() + public double CpuUtilizationV2() { DateTimeOffset now = _timeProvider.GetUtcNow(); - double actualElapsedNanoseconds = (now - _lastCpuMeasurementTime).TotalNanoseconds; lock (_cpuLocker) { if (now < _refreshAfterCpu) @@ -160,79 +138,34 @@ public double CpuUtilizationWithoutHostDelta() } } - var (cpuUsageTime, cpuPeriodCounter) = _parser.GetCgroupCpuUsageInNanosecondsAndCpuPeriodsV2(); + (long cpuUsageTime, long cpuPeriodCounter) = _parser.GetCgroupCpuUsageInNanosecondsAndCpuPeriodsV2(); lock (_cpuLocker) { - if (now >= _refreshAfterCpu) + if (now < _refreshAfterCpu) { - long deltaCgroup = cpuUsageTime - _previousCgroupCpuTime; - double coresUsed; - - if (_useDeltaNrPeriods) - { - long deltaPeriodCount = cpuPeriodCounter - _previousCgroupCpuPeriodCounter; - long deltaCpuPeriodInNanoseconds = deltaPeriodCount * _cpuPeriodsInterval * 1000; - - if (deltaCgroup > 0 && deltaPeriodCount > 0) - { - coresUsed = deltaCgroup / (double)deltaCpuPeriodInNanoseconds; - - _logger.CpuUsageDataV2(cpuUsageTime, _previousCgroupCpuTime, deltaCpuPeriodInNanoseconds, coresUsed); - - _lastCpuCoresUsed = coresUsed; - _refreshAfterCpu = now.Add(_cpuRefreshInterval); - _previousCgroupCpuTime = cpuUsageTime; - _previousCgroupCpuPeriodCounter = cpuPeriodCounter; - } - } - else - { - if (deltaCgroup > 0) - { - coresUsed = deltaCgroup / actualElapsedNanoseconds; - - _logger.CpuUsageDataV2(cpuUsageTime, _previousCgroupCpuTime, actualElapsedNanoseconds, coresUsed); - - _lastCpuCoresUsed = coresUsed; - _refreshAfterCpu = now.Add(_cpuRefreshInterval); - _previousCgroupCpuTime = cpuUsageTime; - - // Update the timestamp for next calculation - _lastCpuMeasurementTime = now; - } - } + return _lastCpuCoresUsed; } - } - return _lastCpuCoresUsed; - } + long deltaCgroup = cpuUsageTime - _previousCgroupCpuTime; + long deltaPeriodCount = cpuPeriodCounter - _previousCgroupCpuPeriodCounter; - /// - /// Calculates CPU utilization relative to the CPU limit. - /// - /// The CPU limit to use for the calculation. - /// CPU usage as a ratio of the limit. - public double CpuUtilizationLimit(float cpuLimit) - { - double utilization = CpuUtilizationWithoutHostDelta() / cpuLimit; + if (deltaCgroup <= 0 || deltaPeriodCount <= 0) + { + return _lastCpuCoresUsed; + } - // Increment counter if utilization exceeds 1 (100%) - if (utilization > 1.0) - { - _cpuUtilizationLimit100PercentExceededCounter?.Add(1); - _cpuUtilizationLimit100PercentExceeded++; - _logger.CounterMessage100(_cpuUtilizationLimit100PercentExceeded); - } + long deltaCpuPeriodInNanoseconds = deltaPeriodCount * _cpuPeriodsInterval * 1000; + double coresUsed = deltaCgroup / (double)deltaCpuPeriodInNanoseconds; - // Increment counter if utilization exceeds 110% - if (utilization > CpuLimitThreshold110Percent) - { - _cpuUtilizationLimit110PercentExceededCounter?.Add(1); - _cpuUtilizationLimit110PercentExceeded++; - _logger.CounterMessage110(_cpuUtilizationLimit110PercentExceeded); + _logger.CpuUsageDataV2(cpuUsageTime, _previousCgroupCpuTime, deltaCpuPeriodInNanoseconds, coresUsed); + + _lastCpuCoresUsed = coresUsed; + _refreshAfterCpu = now.Add(_cpuRefreshInterval); + _previousCgroupCpuTime = cpuUsageTime; + _previousCgroupCpuPeriodCounter = cpuPeriodCounter; } - return utilization; + return _lastCpuCoresUsed; } public double CpuUtilization() @@ -252,23 +185,27 @@ public double CpuUtilization() lock (_cpuLocker) { - if (now >= _refreshAfterCpu) + if (now < _refreshAfterCpu) { - long deltaHost = hostCpuTime - _previousHostCpuTime; - long deltaCgroup = cgroupCpuTime - _previousCgroupCpuTime; - - if (deltaHost > 0 && deltaCgroup > 0) - { - double percentage = Math.Min(One, (double)deltaCgroup / deltaHost); + return _cpuPercentage; + } - _logger.CpuUsageData(cgroupCpuTime, hostCpuTime, _previousCgroupCpuTime, _previousHostCpuTime, percentage); + long deltaHost = hostCpuTime - _previousHostCpuTime; + long deltaCgroup = cgroupCpuTime - _previousCgroupCpuTime; - _cpuPercentage = percentage; - _refreshAfterCpu = now.Add(_cpuRefreshInterval); - _previousCgroupCpuTime = cgroupCpuTime; - _previousHostCpuTime = hostCpuTime; - } + if (deltaHost <= 0 || deltaCgroup <= 0) + { + return _cpuPercentage; } + + double percentage = Math.Min(One, (double)deltaCgroup / deltaHost); + + _logger.CpuUsageData(cgroupCpuTime, hostCpuTime, _previousCgroupCpuTime, _previousHostCpuTime, percentage); + + _cpuPercentage = percentage; + _refreshAfterCpu = now.Add(_cpuRefreshInterval); + _previousCgroupCpuTime = cgroupCpuTime; + _previousHostCpuTime = hostCpuTime; } return _cpuPercentage; @@ -351,4 +288,9 @@ ex is System.IO.DirectoryNotFoundException || return Enumerable.Empty>(); } } + + // Math.Min() is used below to mitigate margin errors and various kinds of precisions losses + // due to the fact that the calculation itself is not an atomic operation: + private double CpuUtilizationRequest(double cpuRequest) => Math.Min(One, CpuUtilizationV2() / cpuRequest); + private double CpuUtilizationLimit(double cpuLimit) => Math.Min(One, CpuUtilizationV2() / cpuLimit); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs index b78f64ddfe0..209a495e844 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs @@ -50,19 +50,7 @@ public static partial void CpuUsageDataV2( double actualElapsedNanoseconds, double cpuCores); - [LoggerMessage(5, LogLevel.Debug, - "CPU utilization exceeded 100%: Counter = {counterValue}")] - public static partial void CounterMessage100( - this ILogger logger, - long counterValue); - - [LoggerMessage(6, LogLevel.Debug, - "CPU utilization exceeded 110%: Counter = {counterValue}")] - public static partial void CounterMessage110( - this ILogger logger, - long counterValue); - - [LoggerMessage(7, LogLevel.Warning, + [LoggerMessage(5, LogLevel.Warning, "Error while getting disk stats: Error={errorMessage}")] public static partial void HandleDiskStatsException( this ILogger logger, diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs index 420d6001f57..eb890da291e 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs @@ -100,23 +100,18 @@ public partial class ResourceMonitoringOptions public TimeSpan MemoryConsumptionRefreshInterval { get; set; } = DefaultRefreshInterval; /// - /// Gets or sets a value indicating whether CPU metrics are calculated via cgroup CPU limits instead of Host CPU delta. + /// Gets or sets a value indicating whether CPU metrics for Linux are calculated using V2 method - via cgroup CPU limits instead of Host CPU delta. /// /// /// The default value is . /// + /// + /// This applies to cgroups v2 only and not supported on cgroups v1. + /// This is a more accurate way to calculate CPU utilization on Linux systems, please enable if possible. + /// It will be the default in the future. + /// [Experimental(diagnosticId: DiagnosticIds.Experiments.ResourceMonitoring, UrlFormat = DiagnosticIds.UrlFormat)] - public bool CalculateCpuUsageWithoutHostDelta { get; set; } - - /// - /// Gets or sets a value indicating whether to use the number of periods in cpu.stat for cgroup CPU usage. - /// We use delta time for CPU usage calculation when this flag is not set. - /// - /// The default value is . - /// - /// - [Experimental(diagnosticId: DiagnosticIds.Experiments.ResourceMonitoring, UrlFormat = DiagnosticIds.UrlFormat)] - public bool UseDeltaNrPeriodsForCpuCalculation { get; set; } + public bool UseLinuxCalculationV2 { get; set; } /// /// Gets or sets a value indicating whether disk I/O metrics should be enabled. diff --git a/src/Shared/Instruments/ResourceUtilizationInstruments.cs b/src/Shared/Instruments/ResourceUtilizationInstruments.cs index 3b3e4f80ea2..c0a230c84ea 100644 --- a/src/Shared/Instruments/ResourceUtilizationInstruments.cs +++ b/src/Shared/Instruments/ResourceUtilizationInstruments.cs @@ -89,22 +89,6 @@ internal static class ResourceUtilizationInstruments /// The type of an instrument is . /// public const string SystemNetworkConnections = "system.network.connections"; - - /// - /// The name of an instrument to count occurrences when CPU utilization exceeds 100% of the limit. - /// - /// - /// The type of an instrument is . - /// - public const string CpuUtilizationLimit100PercentExceeded = "cpu.utilization.limit.100percent.exceeded"; - - /// - /// The name of an instrument to count occurrences when CPU utilization exceeds 110% of the limit. - /// - /// - /// The type of an instrument is . - /// - public const string CpuUtilizationLimit110PercentExceeded = "cpu.utilization.limit.110percent.exceeded"; } #pragma warning disable CS1574 diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs index efaaea1a51a..b71ea1c47d2 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs @@ -359,82 +359,6 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou return Task.CompletedTask; } - [ConditionalFact] - [CombinatorialData] - [OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] - public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgroupsv2_v2() - { - var cpuRefresh = TimeSpan.FromMinutes(13); - var memoryRefresh = TimeSpan.FromMinutes(14); - var fileSystem = new HardcodedValueFileSystem(new Dictionary - { - { new FileInfo("/proc/self/cgroup"), "0::/fakeslice"}, - { new FileInfo("/proc/stat"), "cpu 10 10 10 10 10 10 10 10 10 10"}, - { new FileInfo("/sys/fs/cgroup/fakeslice/cpu.stat"), "usage_usec 1020000\nnr_periods 50"}, - { new FileInfo("/sys/fs/cgroup/memory.max"), "1048576" }, - { new FileInfo("/proc/meminfo"), "MemTotal: 1024 kB"}, - { new FileInfo("/sys/fs/cgroup/cpuset.cpus.effective"), "0-19"}, - { new FileInfo("/sys/fs/cgroup/fakeslice/cpu.max"), "40000 10000"}, - { new FileInfo("/sys/fs/cgroup/fakeslice/cpu.weight"), "79"}, - }); - - using var listener = new MeterListener(); - var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); - var cpuFromGauge = 0.0d; - var cpuLimitFromGauge = 0.0d; - var cpuRequestFromGauge = 0.0d; - var memoryFromGauge = 0.0d; - var memoryLimitFromGauge = 0.0d; - using var e = new ManualResetEventSlim(); - - object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); - listener.Start(); - - using var host = FakeHost.CreateBuilder() - .ConfigureServices(x => - x.AddLogging() - .AddSingleton(clock) - .AddSingleton(new FakeUserHz(100)) - .AddSingleton(fileSystem) - .AddSingleton(new GenericPublisher(_ => e.Set())) - .AddResourceMonitoring(x => x.ConfigureMonitor(options => options.CalculateCpuUsageWithoutHostDelta = true)) - .Replace(ServiceDescriptor.Singleton())) - .Build(); - - meterScope = host.Services.GetRequiredService(); - var tracker = host.Services.GetService(); - Assert.NotNull(tracker); - - _ = host.RunAsync(); - - listener.RecordObservableInstruments(); - - var utilization = tracker.GetUtilization(TimeSpan.FromSeconds(5)); - - fileSystem.ReplaceFileContent(new FileInfo("/proc/stat"), "cpu 11 10 10 10 10 10 10 10 10 10"); - fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/fakeslice/cpu.stat"), "usage_usec 1120000\nnr_periods 56"); - fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/memory.current"), "524298"); - fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/memory.stat"), "inactive_file 10"); - - clock.Advance(TimeSpan.FromSeconds(1)); - listener.RecordObservableInstruments(); - - e.Wait(); - - utilization = tracker.GetUtilization(TimeSpan.FromSeconds(1)); - - var roundedCpuUsedPercentage = Math.Round(utilization.CpuUsedPercentage, 1); - - Assert.Equal(0, Math.Round(cpuLimitFromGauge * 100)); - Assert.Equal(0, Math.Round(cpuRequestFromGauge * 100)); - - return Task.CompletedTask; - } - [ConditionalFact] [CombinatorialData] [OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] @@ -479,8 +403,7 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou .AddSingleton(new GenericPublisher(_ => e.Set())) .AddResourceMonitoring(x => x.ConfigureMonitor(options => { - options.CalculateCpuUsageWithoutHostDelta = true; - options.UseDeltaNrPeriodsForCpuCalculation = true; + options.UseLinuxCalculationV2 = true; })) .Replace(ServiceDescriptor.Singleton())) .Build(); diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs index 6ee3c40d44d..57b92bcfa85 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs @@ -213,7 +213,7 @@ public void Provider_Registers_Instruments_CgroupV2_WithoutHostCpu() { var meterName = Guid.NewGuid().ToString(); var logger = new FakeLogger(); - var options = Options.Options.Create(new ResourceMonitoringOptions { CalculateCpuUsageWithoutHostDelta = true }); + var options = Options.Options.Create(new ResourceMonitoringOptions { UseLinuxCalculationV2 = true }); using var meter = new Meter(nameof(Provider_Registers_Instruments_CgroupV2_WithoutHostCpu)); var meterFactoryMock = new Mock(); meterFactoryMock.Setup(x => x.Create(It.IsAny())) diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs index 1a40889fd72..ca3126e8b97 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs @@ -19,7 +19,7 @@ public void Basic() }; Assert.NotNull(options); - Assert.False(options.CalculateCpuUsageWithoutHostDelta); + Assert.False(options.UseLinuxCalculationV2); } [Fact] @@ -27,9 +27,9 @@ public void CalculateCpuUsageWithoutHostDelta_WhenSet_ReturnsExpectedValue() { var options = new ResourceMonitoringOptions { - CalculateCpuUsageWithoutHostDelta = true + UseLinuxCalculationV2 = true }; - Assert.True(options.CalculateCpuUsageWithoutHostDelta); + Assert.True(options.UseLinuxCalculationV2); } } From 2c2f51f35edbdb4dcb3ee1c52c384f70ae2f463e Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 3 Jul 2025 14:09:56 +0300 Subject: [PATCH 13/71] AIFunctionFactory: tolerate JSON string function parameters. (#6572) * AIFunctionFactory: tolerate JSON string function parameters. * Add debug assertion. * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs Co-authored-by: Stephen Toub * Add regex-based JSON string recognition and add more tests. --------- Co-authored-by: Stephen Toub --- .../Functions/AIFunctionFactory.cs | 47 ++++++++++++++ .../ChatClientIntegrationTests.cs | 33 ++++++++++ .../Functions/AIFunctionFactoryTest.cs | 61 +++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 320df4098a3..e864923883e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -26,6 +26,7 @@ #pragma warning disable S2333 // Redundant modifiers should not be used #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1202 // Public members should come before private members +#pragma warning disable SA1203 // Constants should appear before fields namespace Microsoft.Extensions.AI; @@ -825,6 +826,23 @@ static bool IsAsyncMethod(MethodInfo method) { try { + if (value is string text && IsPotentiallyJson(text)) + { + Debug.Assert(typeInfo.Type != typeof(string), "string parameters should not enter this branch."); + + // Account for the parameter potentially being a JSON string. + // The value is a string but the type is not. Try to deserialize it under the assumption that it's JSON. + // If it's not, we'll fall through to the default path that makes it valid JSON and then tries to deserialize. + try + { + return JsonSerializer.Deserialize(text, typeInfo); + } + catch (JsonException) + { + // If the string is not valid JSON, fall through to the round-trip. + } + } + string json = JsonSerializer.Serialize(value, serializerOptions.GetTypeInfo(value.GetType())); return JsonSerializer.Deserialize(json, typeInfo); } @@ -1021,6 +1039,35 @@ private record struct DescriptorKey( AIJsonSchemaCreateOptions SchemaOptions); } + /// + /// Quickly checks if the specified string is potentially JSON + /// by checking if the first non-whitespace characters are valid JSON start tokens. + /// + /// The string to check. + /// If then the string is definitely not valid JSON. + private static bool IsPotentiallyJson(string value) => PotentiallyJsonRegex().IsMatch(value); +#if NET + [GeneratedRegex(PotentiallyJsonRegexString, RegexOptions.IgnorePatternWhitespace)] + private static partial Regex PotentiallyJsonRegex(); +#else + private static Regex PotentiallyJsonRegex() => _potentiallyJsonRegex; + private static readonly Regex _potentiallyJsonRegex = new(PotentiallyJsonRegexString, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); +#endif + private const string PotentiallyJsonRegexString = """ + ^\s* # Optional whitespace at the start of the string + ( null # null literal + | false # false literal + | true # true literal + | \d # positive number + | -\d # negative number + | " # string + | \[ # start array + | { # start object + | // # Start of single-line comment + | /\* # Start of multi-line comment + ) + """; + /// /// Removes characters from a .NET member name that shouldn't be used in an AI function name. /// diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index d84d767fd4c..ffa94f64531 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -341,6 +341,39 @@ public virtual async Task FunctionInvocation_NestedParameters() AssertUsageAgainstActivities(response, activities); } + [ConditionalFact] + public virtual async Task FunctionInvocation_ArrayParameter() + { + SkipIfNotEnabled(); + + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var chatClient = new FunctionInvokingChatClient( + new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + + List messages = + [ + new(ChatRole.User, "Can you add bacon, lettuce, and tomatoes to Peter's shopping cart?") + ]; + + string? shopperName = null; + List shoppingCart = []; + AIFunction func = AIFunctionFactory.Create((string[] items, string shopperId) => { shoppingCart.AddRange(items); shopperName = shopperId; }, "AddItemsToShoppingCart"); + var response = await chatClient.GetResponseAsync(messages, new() + { + Tools = [func] + }); + + Assert.Equal("Peter", shopperName); + Assert.Equal(["bacon", "lettuce", "tomatoes"], shoppingCart); + AssertUsageAgainstActivities(response, activities); + } + private static void AssertUsageAgainstActivities(ChatResponse response, List activities) { // If the underlying IChatClient provides usage data, function invocation should aggregate the diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 84298788e8c..b15d200a39a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -75,6 +76,66 @@ public async Task Parameters_MissingRequiredParametersFail_Async() } } + [Fact] + public async Task Parameters_ToleratesJsonEncodedParameters() + { + AIFunction func = AIFunctionFactory.Create((int x, int y, int z, int w, int u) => x + y + z + w + u); + + var result = await func.InvokeAsync(new() + { + ["x"] = "1", + ["y"] = JsonNode.Parse("2"), + ["z"] = JsonDocument.Parse("3"), + ["w"] = JsonDocument.Parse("4").RootElement, + ["u"] = 5M, // boxed decimal cannot be cast to int, requires conversion + }); + + AssertExtensions.EqualFunctionCallResults(15, result); + } + + [Theory] + [InlineData(" null")] + [InlineData(" false ")] + [InlineData("true ")] + [InlineData("42")] + [InlineData("0.0")] + [InlineData("-1e15")] + [InlineData(" \"I am a string!\" ")] + [InlineData(" {}")] + [InlineData("[]")] + public async Task Parameters_ToleratesJsonStringParameters(string jsonStringParam) + { + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param); + JsonElement expectedResult = JsonDocument.Parse(jsonStringParam).RootElement; + + var result = await func.InvokeAsync(new() + { + ["param"] = jsonStringParam + }); + + AssertExtensions.EqualFunctionCallResults(expectedResult, result); + } + + [Theory] + [InlineData("")] + [InlineData(" \r\n")] + [InlineData("I am a string!")] + [InlineData("/* Code snippet */ int main(void) { return 0; }")] + [InlineData("let rec Y F x = F (Y F) x")] + [InlineData("+3")] + public async Task Parameters_ToleratesInvalidJsonStringParameters(string invalidJsonParam) + { + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param); + JsonElement expectedResult = JsonDocument.Parse(JsonSerializer.Serialize(invalidJsonParam, JsonContext.Default.String)).RootElement; + + var result = await func.InvokeAsync(new() + { + ["param"] = invalidJsonParam + }); + + AssertExtensions.EqualFunctionCallResults(expectedResult, result); + } + [Fact] public async Task Parameters_MappedByType_Async() { From 3b9643582907b4f9981abd1f035df987b7224ddd Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 3 Jul 2025 18:14:36 +0300 Subject: [PATCH 14/71] AIFunctionFactory: add test coverage for JSON comments. (#6576) --- .../Functions/AIFunctionFactory.cs | 3 +-- .../Functions/AIFunctionFactoryTest.cs | 7 +++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index e864923883e..5ad178e7bc8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -1058,8 +1058,7 @@ private record struct DescriptorKey( ( null # null literal | false # false literal | true # true literal - | \d # positive number - | -\d # negative number + | -?[0-9]# number | " # string | \[ # start array | { # start object diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index b15d200a39a..afced22038f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -103,10 +103,13 @@ public async Task Parameters_ToleratesJsonEncodedParameters() [InlineData(" \"I am a string!\" ")] [InlineData(" {}")] [InlineData("[]")] + [InlineData("// single-line comment\r\nnull")] + [InlineData("/* multi-line\r\ncomment */\r\nnull")] public async Task Parameters_ToleratesJsonStringParameters(string jsonStringParam) { - AIFunction func = AIFunctionFactory.Create((JsonElement param) => param); - JsonElement expectedResult = JsonDocument.Parse(jsonStringParam).RootElement; + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { ReadCommentHandling = JsonCommentHandling.Skip }; + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param, serializerOptions: options); + JsonElement expectedResult = JsonDocument.Parse(jsonStringParam, new() { CommentHandling = JsonCommentHandling.Skip }).RootElement; var result = await func.InvokeAsync(new() { From 34ed44a1843070a4c895a55b5b2344774c167d2f Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 4 Jul 2025 19:56:19 -0400 Subject: [PATCH 15/71] Update M.E.AI.OpenAI for latest OpenAI release (#6577) --- eng/packages/General.props | 2 +- .../SpeechToText/SpeechToTextOptions.cs | 14 +- .../Microsoft.Extensions.AI.OpenAI.csproj | 6 +- .../OpenAIAssistantChatClient.cs | 2 - .../OpenAIChatClient.cs | 47 +++-- .../OpenAIClientExtensions.cs | 4 +- .../OpenAIRealtimeConversationClient.cs | 2 +- .../OpenAIResponseChatClient.cs | 48 +++-- .../OpenAISpeechToTextClient.cs | 199 ++++++++---------- ...icrosoft.Extensions.AI.OpenAI.Tests.csproj | 3 +- .../OpenAIAIFunctionConversionTests.cs | 14 +- ...enAIAssistantChatClientIntegrationTests.cs | 3 +- .../OpenAIAssistantChatClientTests.cs | 12 +- .../OpenAIChatClientTests.cs | 19 +- .../OpenAIEmbeddingGeneratorTests.cs | 11 +- .../OpenAIResponseClientTests.cs | 11 +- ...penAISpeechToTextClientIntegrationTests.cs | 2 +- .../OpenAISpeechToTextClientTests.cs | 14 +- 18 files changed, 186 insertions(+), 227 deletions(-) diff --git a/eng/packages/General.props b/eng/packages/General.props index fa2d51de886..aa9771bfb4a 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -16,7 +16,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs index 5ff0135cec7..8efbf510164 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs @@ -11,20 +11,20 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public class SpeechToTextOptions { + /// Gets or sets any additional properties associated with the options. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + /// Gets or sets the model ID for the speech to text. public string? ModelId { get; set; } /// Gets or sets the language of source speech. public string? SpeechLanguage { get; set; } - /// Gets or sets the language for the target generated text. - public string? TextLanguage { get; set; } - /// Gets or sets the sample rate of the speech input audio. public int? SpeechSampleRate { get; set; } - /// Gets or sets any additional properties associated with the options. - public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + /// Gets or sets the language for the target generated text. + public string? TextLanguage { get; set; } /// /// Gets or sets a callback responsible for creating the raw representation of the embedding generation options from an underlying implementation. @@ -51,11 +51,11 @@ public virtual SpeechToTextOptions Clone() { SpeechToTextOptions options = new() { + AdditionalProperties = AdditionalProperties?.Clone(), ModelId = ModelId, SpeechLanguage = SpeechLanguage, - TextLanguage = TextLanguage, SpeechSampleRate = SpeechSampleRate, - AdditionalProperties = AdditionalProperties?.Clone(), + TextLanguage = TextLanguage, }; return options; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj index 552d45f0fc6..a135ee011ea 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj @@ -1,4 +1,4 @@ - + Microsoft.Extensions.AI @@ -15,8 +15,8 @@ $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002;OPENAI002 - $(NoWarn);MEAI001 + $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002 + $(NoWarn);OPENAI001;OPENAI002;MEAI001 true true true diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs index 0b6c5f5122f..c1d59e30a68 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -28,7 +27,6 @@ namespace Microsoft.Extensions.AI; /// Represents an for an Azure.AI.Agents.Persistent . -[Experimental("OPENAI001")] internal sealed class OpenAIAssistantChatClient : IChatClient { /// The underlying . diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index c051550d493..3be0a1cc1ee 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -91,7 +91,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( // Make the call to OpenAI. var chatCompletionUpdates = _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); - return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, cancellationToken); + return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, openAIOptions, cancellationToken); } /// @@ -290,7 +290,8 @@ private static List ToOpenAIChatContent(IList private static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( IAsyncEnumerable updates, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + ChatCompletionOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) { Dictionary? functionCallInfos = null; ChatRole? streamedRole = null; @@ -334,6 +335,14 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha } } + if (update.OutputAudioUpdate is { } audioUpdate) + { + responseUpdate.Contents.Add(new DataContent(audioUpdate.AudioBytesUpdate.ToMemory(), GetOutputAudioMimeType(options)) + { + RawRepresentation = audioUpdate, + }); + } + // Transfer over refusal updates. if (update.RefusalUpdate is not null) { @@ -363,8 +372,10 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha // Transfer over usage updates. if (update.Usage is ChatTokenUsage tokenUsage) { - var usageDetails = FromOpenAIUsage(tokenUsage); - responseUpdate.Contents.Add(new UsageContent(usageDetails)); + responseUpdate.Contents.Add(new UsageContent(FromOpenAIUsage(tokenUsage)) + { + RawRepresentation = tokenUsage, + }); } // Now yield the item. @@ -408,6 +419,17 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha } } + private static string GetOutputAudioMimeType(ChatCompletionOptions? options) => + options?.AudioOptions?.OutputAudioFormat.ToString()?.ToLowerInvariant() switch + { + "opus" => "audio/opus", + "aac" => "audio/aac", + "flac" => "audio/flac", + "wav" => "audio/wav", + "pcm" => "audio/pcm", + "mp3" or _ => "audio/mpeg", + }; + private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatOptions? options, ChatCompletionOptions chatCompletionOptions) { _ = Throw.IfNull(openAICompletion); @@ -432,19 +454,10 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple // Output audio is handled separately from message content parts. if (openAICompletion.OutputAudio is ChatOutputAudio audio) { - string mimeType = chatCompletionOptions?.AudioOptions?.OutputAudioFormat.ToString()?.ToLowerInvariant() switch + returnMessage.Contents.Add(new DataContent(audio.AudioBytes.ToMemory(), GetOutputAudioMimeType(chatCompletionOptions)) { - "opus" => "audio/opus", - "aac" => "audio/aac", - "flac" => "audio/flac", - "wav" => "audio/wav", - "pcm" => "audio/pcm", - "mp3" or _ => "audio/mpeg", - }; - - var dc = new DataContent(audio.AudioBytes.ToMemory(), mimeType); - - returnMessage.Contents.Add(dc); + RawRepresentation = audio, + }); } // Also manufacture function calling content items from any tool calls in the response. @@ -505,9 +518,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.PresencePenalty ??= options.PresencePenalty; result.Temperature ??= options.Temperature; result.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. result.Seed ??= options.Seed; -#pragma warning restore OPENAI001 if (options.StopSequences is { Count: > 0 } stopSequences) { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index b20769c0dc4..dccddf3038e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -14,7 +14,7 @@ using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Embeddings; -using OpenAI.RealtimeConversation; +using OpenAI.Realtime; using OpenAI.Responses; #pragma warning disable S103 // Lines should not be too long @@ -134,7 +134,6 @@ public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient /// is . /// is . /// is empty or composed entirely of whitespace. - [Experimental("OPENAI001")] // AssistantClient itself is experimental with this ID public static IChatClient AsIChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) => new OpenAIAssistantChatClient(assistantClient, assistantId, threadId); @@ -165,7 +164,6 @@ public static ChatTool AsOpenAIChatTool(this AIFunction function) => /// The function to convert. /// An OpenAI representing . /// is . - [Experimental("OPENAI001")] // AssistantClient itself is experimental with this ID public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) => OpenAIAssistantChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs index abfebd99f34..7c944ac5edb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using OpenAI.RealtimeConversation; +using OpenAI.Realtime; namespace Microsoft.Extensions.AI; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index 46019166719..6aee4bc77e4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -117,6 +117,13 @@ public async Task GetResponseAsync( ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); break; + case ReasoningResponseItem reasoningItem when reasoningItem.GetSummaryText() is string summary && !string.IsNullOrWhiteSpace(summary): + message.Contents.Add(new TextReasoningContent(summary) + { + RawRepresentation = reasoningItem + }); + break; + case FunctionCallResponseItem functionCall: response.FinishReason ??= ChatFinishReason.ToolCalls; var fcc = FunctionCallContent.CreateFromParsedArguments( @@ -139,7 +146,7 @@ public async Task GetResponseAsync( if (openAIResponse.Error is { } error) { - message.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code }); + message.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); } } @@ -367,10 +374,11 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt // Handle strongly-typed properties. result.MaxOutputTokenCount ??= options.MaxOutputTokens; + result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; result.PreviousResponseId ??= options.ConversationId; - result.TopP ??= options.TopP; result.Temperature ??= options.Temperature; - result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; + result.TopP ??= options.TopP; + if (options.Instructions is { } instructions) { result.Instructions = string.IsNullOrEmpty(result.Instructions) ? @@ -386,22 +394,21 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt switch (tool) { case AIFunction aiFunction: - ResponseTool rtool = ToResponseTool(aiFunction, options); - result.Tools.Add(rtool); + result.Tools.Add(ToResponseTool(aiFunction, options)); break; case HostedWebSearchTool: - WebSearchToolLocation? location = null; - if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchToolLocation), out object? objLocation)) + WebSearchUserLocation? location = null; + if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchUserLocation), out object? objLocation)) { - location = objLocation as WebSearchToolLocation; + location = objLocation as WebSearchUserLocation; } - WebSearchToolContextSize? size = null; - if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchToolContextSize), out object? objSize) && - objSize is WebSearchToolContextSize) + WebSearchContextSize? size = null; + if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchContextSize), out object? objSize) && + objSize is WebSearchContextSize) { - size = (WebSearchToolContextSize)objSize; + size = (WebSearchContextSize)objSize; } result.Tools.Add(ResponseTool.CreateWebSearchTool(location, size)); @@ -522,6 +529,10 @@ private static IEnumerable ToOpenAIResponseItems( yield return ResponseItem.CreateAssistantMessageItem(textContent.Text); break; + case TextReasoningContent reasoningContent: + yield return ResponseItem.CreateReasoningItem(reasoningContent.Text); + break; + case FunctionCallContent callContent: yield return ResponseItem.CreateFunctionCallItem( callContent.CallId, @@ -555,12 +566,16 @@ private static IEnumerable ToOpenAIResponseItems( TotalTokenCount = usage.TotalTokenCount, }; - if (usage.OutputTokenDetails is { } outputDetails) + if (usage.InputTokenDetails is { } inputDetails) { ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); + } - const string OutputDetails = nameof(usage.OutputTokenDetails); - ud.AdditionalCounts.Add($"{OutputDetails}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); + if (usage.OutputTokenDetails is { } outputDetails) + { + ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); } } @@ -624,8 +639,7 @@ private static List ToOpenAIResponsesContent(IListDefault OpenAI endpoint. - private static readonly Uri _defaultOpenAIEndpoint = new("https://api.openai.com/v1"); + /// Filename to use when audio lacks a name. + /// This information internally is required but is only being used to create a header name in the multipart request. + private const string Filename = "audio.mp3"; /// Metadata about the client. private readonly SpeechToTextClientMetadata _metadata; @@ -45,7 +46,7 @@ public OpenAISpeechToTextClient(AudioClient audioClient) // implement the abstractions directly rather than providing adapters on top of the public APIs, // the package can provide such implementations separate from what's exposed in the public API. Uri providerUrl = typeof(AudioClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(audioClient) as Uri ?? _defaultOpenAIEndpoint; + ?.GetValue(audioClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint; string? model = typeof(AudioClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(audioClient) as string; @@ -65,20 +66,6 @@ public OpenAISpeechToTextClient(AudioClient audioClient) null; } - /// - public async IAsyncEnumerable GetStreamingTextAsync( - Stream audioSpeechStream, SpeechToTextOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(audioSpeechStream); - - var speechResponse = await GetTextAsync(audioSpeechStream, options, cancellationToken).ConfigureAwait(false); - - foreach (var update in speechResponse.ToSpeechToTextResponseUpdates()) - { - yield return update; - } - } - /// public async Task GetTextAsync( Stream audioSpeechStream, SpeechToTextOptions? options = null, CancellationToken cancellationToken = default) @@ -87,140 +74,126 @@ public async Task GetTextAsync( SpeechToTextResponse response = new(); - // A translation is triggered when the target text language is specified and the source language is not provided or different. - static bool IsTranslationRequest(SpeechToTextOptions? options) - => options is not null && options.TextLanguage is not null - && (options.SpeechLanguage is null || options.SpeechLanguage != options.TextLanguage); + string filename = audioSpeechStream is FileStream fileStream ? + Path.GetFileName(fileStream.Name) : // Use the file name if we can get one from the stream. + Filename; // Otherwise, use a default name; this is only used to create a header name in the multipart request. if (IsTranslationRequest(options)) { - _ = Throw.IfNull(options); + var translation = (await _audioClient.TranslateAudioAsync(audioSpeechStream, filename, ToOpenAITranslationOptions(options), cancellationToken).ConfigureAwait(false)).Value; - var openAIOptions = ToOpenAITranslationOptions(options); - AudioTranslation translationResult; + response.Contents = [new TextContent(translation.Text)]; + response.RawRepresentation = translation; -#if NET - await using (audioSpeechStream.ConfigureAwait(false)) -#else - using (audioSpeechStream) -#endif + int segmentCount = translation.Segments.Count; + if (segmentCount > 0) { - translationResult = (await _audioClient.TranslateAudioAsync( - audioSpeechStream, - "file.wav", // this information internally is required but is only being used to create a header name in the multipart request. - openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + response.StartTime = translation.Segments[0].StartTime; + response.EndTime = translation.Segments[segmentCount - 1].EndTime; } - - UpdateResponseFromOpenAIAudioTranslation(response, translationResult); } else { - var openAIOptions = ToOpenAITranscriptionOptions(options); + var transcription = (await _audioClient.TranscribeAudioAsync(audioSpeechStream, filename, ToOpenAITranscriptionOptions(options), cancellationToken).ConfigureAwait(false)).Value; - // Transcription request - AudioTranscription transcriptionResult; + response.Contents = [new TextContent(transcription.Text)]; + response.RawRepresentation = transcription; -#if NET - await using (audioSpeechStream.ConfigureAwait(false)) -#else - using (audioSpeechStream) -#endif + int segmentCount = transcription.Segments.Count; + if (segmentCount > 0) { - transcriptionResult = (await _audioClient.TranscribeAudioAsync( - audioSpeechStream, - "file.wav", // this information internally is required but is only being used to create a header name in the multipart request. - openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + response.StartTime = transcription.Segments[0].StartTime; + response.EndTime = transcription.Segments[segmentCount - 1].EndTime; + } + else + { + int wordCount = transcription.Words.Count; + if (wordCount > 0) + { + response.StartTime = transcription.Words[0].StartTime; + response.EndTime = transcription.Words[wordCount - 1].EndTime; + } } - - UpdateResponseFromOpenAIAudioTranscription(response, transcriptionResult); } return response; } /// - void IDisposable.Dispose() - { - // Nothing to dispose. Implementation required for the IAudioTranscriptionClient interface. - } - - /// Updates a from an OpenAI . - /// The response to update. - /// The OpenAI audio transcription. - private static void UpdateResponseFromOpenAIAudioTranscription(SpeechToTextResponse response, AudioTranscription audioTranscription) + public async IAsyncEnumerable GetStreamingTextAsync( + Stream audioSpeechStream, SpeechToTextOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - _ = Throw.IfNull(audioTranscription); + _ = Throw.IfNull(audioSpeechStream); - var segmentCount = audioTranscription.Segments.Count; - var wordCount = audioTranscription.Words.Count; + string filename = audioSpeechStream is FileStream fileStream ? + Path.GetFileName(fileStream.Name) : // Use the file name if we can get one from the stream. + Filename; // Otherwise, use a default name; this is only used to create a header name in the multipart request. - TimeSpan? endTime = null; - TimeSpan? startTime = null; - if (segmentCount > 0) + if (IsTranslationRequest(options)) { - endTime = audioTranscription.Segments[segmentCount - 1].EndTime; - startTime = audioTranscription.Segments[0].StartTime; + foreach (var update in (await GetTextAsync(audioSpeechStream, options, cancellationToken).ConfigureAwait(false)).ToSpeechToTextResponseUpdates()) + { + yield return update; + } } - else if (wordCount > 0) + else { - endTime = audioTranscription.Words[wordCount - 1].EndTime; - startTime = audioTranscription.Words[0].StartTime; + await foreach (var update in _audioClient.TranscribeAudioStreamingAsync( + audioSpeechStream, + filename, + ToOpenAITranscriptionOptions(options), + cancellationToken).ConfigureAwait(false)) + { + SpeechToTextResponseUpdate result = new() + { + ModelId = options?.ModelId, + RawRepresentation = update, + }; + + switch (update) + { + case StreamingAudioTranscriptionTextDeltaUpdate deltaUpdate: + result.Kind = SpeechToTextResponseUpdateKind.TextUpdated; + result.Contents = [new TextContent(deltaUpdate.Delta)]; + break; + + case StreamingAudioTranscriptionTextDoneUpdate doneUpdate: + result.Kind = SpeechToTextResponseUpdateKind.SessionClose; + break; + } + + yield return result; + } } + } - // Update the response - response.RawRepresentation = audioTranscription; - response.Contents = [new TextContent(audioTranscription.Text)]; - response.StartTime = startTime; - response.EndTime = endTime; - response.AdditionalProperties = new AdditionalPropertiesDictionary - { - [nameof(audioTranscription.Language)] = audioTranscription.Language, - [nameof(audioTranscription.Duration)] = audioTranscription.Duration - }; + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IAudioTranscriptionClient interface. } - /// Converts an extensions options instance to an OpenAI options instance. + // A translation is triggered when the target text language is specified and the source language is not provided or different. + private static bool IsTranslationRequest(SpeechToTextOptions? options) => + options is not null && + options.TextLanguage is not null && + (options.SpeechLanguage is null || options.SpeechLanguage != options.TextLanguage); + + /// Converts an extensions options instance to an OpenAI transcription options instance. private AudioTranscriptionOptions ToOpenAITranscriptionOptions(SpeechToTextOptions? options) { - if (options?.RawRepresentationFactory?.Invoke(this) is not AudioTranscriptionOptions result) - { - result = new AudioTranscriptionOptions(); - } + AudioTranscriptionOptions result = options?.RawRepresentationFactory?.Invoke(this) as AudioTranscriptionOptions ?? new(); result.Language ??= options?.SpeechLanguage; + return result; } - /// Updates a from an OpenAI . - /// The response to update. - /// The OpenAI audio translation. - private static void UpdateResponseFromOpenAIAudioTranslation(SpeechToTextResponse response, AudioTranslation audioTranslation) + /// Converts an extensions options instance to an OpenAI translation options instance. + private AudioTranslationOptions ToOpenAITranslationOptions(SpeechToTextOptions? options) { - _ = Throw.IfNull(audioTranslation); - - var segmentCount = audioTranslation.Segments.Count; - - TimeSpan? endTime = null; - TimeSpan? startTime = null; - if (segmentCount > 0) - { - endTime = audioTranslation.Segments[segmentCount - 1].EndTime; - startTime = audioTranslation.Segments[0].StartTime; - } + AudioTranslationOptions result = options?.RawRepresentationFactory?.Invoke(this) as AudioTranslationOptions ?? new(); - // Update the response - response.RawRepresentation = audioTranslation; - response.Contents = [new TextContent(audioTranslation.Text)]; - response.StartTime = startTime; - response.EndTime = endTime; - response.AdditionalProperties = new AdditionalPropertiesDictionary - { - [nameof(audioTranslation.Language)] = audioTranslation.Language, - [nameof(audioTranslation.Duration)] = audioTranslation.Duration - }; + return result; } - - /// Converts an extensions options instance to an OpenAI options instance. - private AudioTranslationOptions ToOpenAITranslationOptions(SpeechToTextOptions? options) - => options?.RawRepresentationFactory?.Invoke(this) as AudioTranslationOptions ?? new AudioTranslationOptions(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj index 60b6f8c84ab..536c250cb47 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj @@ -2,7 +2,8 @@ Microsoft.Extensions.AI Unit tests for Microsoft.Extensions.AI.OpenAI - $(NoWarn);OPENAI002;MEAI001;S104 + $(NoWarn);S104 + $(NoWarn);OPENAI001;MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs index f2f0c9d8a3f..ce458473c59 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs @@ -6,12 +6,10 @@ using System.Text.Json; using OpenAI.Assistants; using OpenAI.Chat; -using OpenAI.RealtimeConversation; +using OpenAI.Realtime; using OpenAI.Responses; using Xunit; -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - namespace Microsoft.Extensions.AI; public class OpenAIAIFunctionConversionTests @@ -24,7 +22,7 @@ public class OpenAIAIFunctionConversionTests [Fact] public void AsOpenAIChatTool_ProducesValidInstance() { - ChatTool tool = _testFunction.AsOpenAIChatTool(); + var tool = _testFunction.AsOpenAIChatTool(); Assert.NotNull(tool); Assert.Equal("test_function", tool.FunctionName); @@ -35,7 +33,7 @@ public void AsOpenAIChatTool_ProducesValidInstance() [Fact] public void AsOpenAIResponseTool_ProducesValidInstance() { - ResponseTool tool = _testFunction.AsOpenAIResponseTool(); + var tool = _testFunction.AsOpenAIResponseTool(); Assert.NotNull(tool); } @@ -43,7 +41,7 @@ public void AsOpenAIResponseTool_ProducesValidInstance() [Fact] public void AsOpenAIConversationFunctionTool_ProducesValidInstance() { - ConversationFunctionTool tool = _testFunction.AsOpenAIConversationFunctionTool(); + var tool = _testFunction.AsOpenAIConversationFunctionTool(); Assert.NotNull(tool); Assert.Equal("test_function", tool.Name); @@ -54,7 +52,7 @@ public void AsOpenAIConversationFunctionTool_ProducesValidInstance() [Fact] public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() { - FunctionToolDefinition tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); + var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); Assert.NotNull(tool); Assert.Equal("test_function", tool.FunctionName); @@ -62,7 +60,7 @@ public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() ValidateSchemaParameters(tool.Parameters); } - /// Helper method to validate function parameters match our schema + /// Helper method to validate function parameters match our schema. private static void ValidateSchemaParameters(BinaryData parameters) { Assert.NotNull(parameters); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs index e616d5fb87b..90bcf9f2632 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable CA1822 // Mark members as static #pragma warning disable CA2000 // Dispose objects before losing scope #pragma warning disable S1135 // Track uses of "TODO" tags @@ -62,7 +61,7 @@ public async Task DeleteAllThreads() client.DefaultRequestHeaders.Add("openai-organization", "org-ENTERYOURORGID"); client.DefaultRequestHeaders.Add("openai-project", "proj_ENTERYOURPROJECTID"); - AssistantClient ac = new AssistantClient(Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!); + AssistantClient ac = new(Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!); while (true) { string listing = await client.GetStringAsync("https://api.openai.com/v1/threads?limit=100"); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs index 6d3a02a08ec..3b084b5ec8a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs @@ -3,7 +3,6 @@ using System; using System.ClientModel; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -11,7 +10,6 @@ using Xunit; #pragma warning disable S103 // Lines should not be too long -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. namespace Microsoft.Extensions.AI; @@ -24,16 +22,12 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient(null!)); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient[] clients = [ diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index edb5d9fab07..d06d8f520be 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -11,7 +11,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -30,17 +29,13 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("chatClient", () => ((ChatClient)null!).AsIChatClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.GetChatClient(model).AsIChatClient(); var metadata = chatClient.GetService(); @@ -398,9 +393,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TopP = 0.5f, PresencePenalty = 0.5f, Temperature = 0.5f, -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, -#pragma warning restore OPENAI001 ToolChoice = ChatToolChoice.CreateAutoChoice(), ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; @@ -477,9 +470,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TopP = 0.5f, PresencePenalty = 0.5f, Temperature = 0.5f, -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, -#pragma warning restore OPENAI001 ToolChoice = ChatToolChoice.CreateAutoChoice(), ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; @@ -561,9 +552,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr Assert.Null(openAIOptions.TopP); Assert.Null(openAIOptions.PresencePenalty); Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 Assert.Empty(openAIOptions.StopSequences); Assert.Empty(openAIOptions.Tools); Assert.Null(openAIOptions.ToolChoice); @@ -637,9 +626,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream Assert.Null(openAIOptions.TopP); Assert.Null(openAIOptions.PresencePenalty); Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 Assert.Empty(openAIOptions.StopSequences); Assert.Empty(openAIOptions.Tools); Assert.Null(openAIOptions.ToolChoice); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index 9d8a1219ea7..43112fa88e3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -6,7 +6,6 @@ using System.ClientModel.Primitives; using System.Net.Http; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -25,17 +24,13 @@ public void AsIEmbeddingGenerator_InvalidArgs_Throws() Assert.Throws("embeddingClient", () => ((EmbeddingClient)null!).AsIEmbeddingGenerator()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IEmbeddingGenerator> embeddingGenerator = client.GetEmbeddingClient(model).AsIEmbeddingGenerator(); var metadata = embeddingGenerator.GetService(); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 28125e462b7..b98eb89197f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -10,7 +10,6 @@ using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -29,17 +28,13 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("responseClient", () => ((OpenAIResponseClient)null!).AsIChatClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.GetOpenAIResponseClient(model).AsIChatClient(); var metadata = chatClient.GetService(); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs index c80b37c865e..bb721806ff6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs @@ -7,6 +7,6 @@ public class OpenAISpeechToTextClientIntegrationTests : SpeechToTextClientIntegr { protected override ISpeechToTextClient? CreateClient() => IntegrationTestHelpers.GetOpenAIClient()? - .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:AudioTranscriptionModel"] ?? "whisper-1") + .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:AudioTranscriptionModel"] ?? "gpt-4o-mini-transcribe") .AsISpeechToTextClient(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs index c92d9627968..1252a20741b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs @@ -8,7 +8,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Logging; using OpenAI; using OpenAI.Audio; @@ -26,17 +25,13 @@ public void AsISpeechToTextClient_InvalidArgs_Throws() Assert.Throws("audioClient", () => ((AudioClient)null!).AsISpeechToTextClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsISpeechToTextClient_AudioClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsISpeechToTextClient_AudioClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); ISpeechToTextClient speechToTextClient = client.GetAudioClient(model).AsISpeechToTextClient(); var metadata = speechToTextClient.GetService(); @@ -148,7 +143,8 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu string input = $$""" { "model": "whisper-1", - "language": "{{speechLanguage}}" + "language": "{{speechLanguage}}", + "stream":true } """; From f9c61b4a372e372b9a11914a7dbdbdca59a912fe Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 4 Jul 2025 19:56:30 -0400 Subject: [PATCH 16/71] Update McpServer template for 0.3.0-preview.2 (#6578) --- src/ProjectTemplates/GeneratedContent.targets | 2 +- .../src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs | 2 +- .../mcpserver/Tools/RandomNumberTools.cs | 2 +- .../mcpserver.Basic.verified/mcpserver/mcpserver.csproj | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index ce6ba2bc502..dfe93dbc5a8 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -39,7 +39,7 @@ 9.3.0 1.53.0 1.53.0-preview - 0.3.0-preview.1 + 0.3.0-preview.2 5.1.18 1.12.0 0.1.10 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs index 4542f8505a5..568574f47d9 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs @@ -7,7 +7,7 @@ /// internal class RandomNumberTools { - [McpServerTool(Name = "get_random_number")] + [McpServerTool] [Description("Generates a random number between the specified minimum and maximum values.")] public int GetRandomNumber( [Description("Minimum value (inclusive)")] int min = 0, diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs index 72af767e320..611745f4129 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs @@ -7,7 +7,7 @@ /// internal class RandomNumberTools { - [McpServerTool(Name = "get_random_number")] + [McpServerTool] [Description("Generates a random number between the specified minimum and maximum values.")] public int GetRandomNumber( [Description("Minimum value (inclusive)")] int min = 0, diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj index a71ac148e6f..e959c64702f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj @@ -26,7 +26,7 @@ - + From 403882c5f385cfc0b3d4b5132b8840141c0f4a6c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Jul 2025 09:25:40 +0300 Subject: [PATCH 17/71] Update OpenTelemetry semantic conventions version from 1.35 to 1.36 (#6579) * Initial plan * Update OpenTelemetry semantic conventions version from 1.35 to 1.36 Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../ChatCompletion/OpenTelemetryChatClient.cs | 2 +- .../Embeddings/OpenTelemetryEmbeddingGenerator.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index d66266c39bc..a759aeea248 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -25,7 +25,7 @@ namespace Microsoft.Extensions.AI; /// Represents a delegating chat 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.35, defined at . +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.36, defined at . /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. /// public sealed partial class OpenTelemetryChatClient : DelegatingChatClient diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs index 2eeb32891ea..87af9e52717 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -19,7 +19,7 @@ namespace Microsoft.Extensions.AI; /// Represents a delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// -/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.35, defined at . +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.36, defined at . /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. /// /// The type of input used to produce embeddings. From 48f95ca63e0b02aba43eb99893d50c10d65ccc57 Mon Sep 17 00:00:00 2001 From: Evgeny Fedorov <25526458+evgenyfedorov2@users.noreply.github.com> Date: Mon, 7 Jul 2025 08:49:07 +0200 Subject: [PATCH 18/71] Add container.cpu.time metric (#5806) --- .../Linux/LinuxUtilizationParserCgroupV1.cs | 2 +- .../Linux/LinuxUtilizationParserCgroupV2.cs | 4 +- .../Linux/LinuxUtilizationProvider.cs | 57 ++++++++++---- .../WindowsContainerSnapshotProvider.cs | 32 +++++--- .../ResourceUtilizationInstruments.cs | 8 ++ .../ResourceHealthCheckExtensionsTests.cs | 1 + .../Linux/AcceptanceTest.cs | 77 ++++++++++++------- .../Linux/LinuxUtilizationProviderTests.cs | 5 +- .../WindowsContainerSnapshotProviderTests.cs | 60 +++++++++++++++ 9 files changed, 192 insertions(+), 54 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs index af90e447df7..5a96d115bb5 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs @@ -151,7 +151,7 @@ public long GetHostCpuUsageInNanoseconds() $"'{_procStat}' should contain whitespace separated values according to POSIX. We've failed trying to get {i}th value. File content: '{new string(stat)}'."); } - stat = stat.Slice(next, stat.Length - next); + stat = stat.Slice(next); } return (long)(total / (double)_userHz * NanosecondsInSecond); diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs index abb4230d6bc..0e367891a96 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs @@ -131,7 +131,7 @@ public string GetCgroupPath(string filename) } // Extract the part after the last colon and cache it for future use - ReadOnlySpan trimmedPath = fileContent.Slice(colonIndex + 1); + ReadOnlySpan trimmedPath = fileContent[(colonIndex + 1)..]; _cachedCgroupPath = "/sys/fs/cgroup" + trimmedPath.ToString().TrimEnd('/') + "/"; return $"{_cachedCgroupPath}{filename}"; @@ -195,7 +195,7 @@ public long GetHostCpuUsageInNanoseconds() $"'{_procStat}' should contain whitespace separated values according to POSIX. We've failed trying to get {i}th value. File content: '{new string(stat)}'."); } - stat = stat.Slice(next, stat.Length - next); + stat = stat.Slice(next); } return (long)(total / (double)_userHz * NanosecondsInSecond); diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs index 5fb2f0a189e..0c3d4124e17 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.Metrics; -using System.Linq; using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -17,6 +16,7 @@ internal sealed class LinuxUtilizationProvider : ISnapshotProvider { private const double One = 1.0; private const long Hundred = 100L; + private const double NanosecondsInSecond = 1_000_000_000; private readonly object _cpuLocker = new(); private readonly object _memoryLocker = new(); @@ -82,14 +82,19 @@ public LinuxUtilizationProvider(IOptions options, ILi (_previousCgroupCpuTime, _previousCgroupCpuPeriodCounter) = _parser.GetCgroupCpuUsageInNanosecondsAndCpuPeriodsV2(); _ = meter.CreateObservableGauge( - ResourceUtilizationInstruments.ContainerCpuLimitUtilization, - () => GetMeasurementWithRetry(() => CpuUtilizationLimit(cpuLimit)), - "1"); + name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilizationLimit(cpuLimit)), + unit: "1"); _ = meter.CreateObservableGauge( name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, observeValues: () => GetMeasurementWithRetry(() => CpuUtilizationRequest(cpuRequest)), unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuTime, + observeValues: GetCpuTime, + unit: "1"); } else { @@ -111,12 +116,12 @@ public LinuxUtilizationProvider(IOptions options, ILi _ = meter.CreateObservableGauge( name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, - observeValues: () => GetMeasurementWithRetry(() => MemoryUtilization()), + observeValues: () => GetMeasurementWithRetry(MemoryUtilization), unit: "1"); _ = meter.CreateObservableGauge( name: ResourceUtilizationInstruments.ProcessMemoryUtilization, - observeValues: () => GetMeasurementWithRetry(() => MemoryUtilization()), + observeValues: () => GetMeasurementWithRetry(MemoryUtilization), unit: "1"); // cpuRequest is a CPU request (aka guaranteed number of CPU units) for pod, for host its 1 core @@ -259,23 +264,32 @@ public Snapshot GetSnapshot() memoryUsageInBytes: memoryUsed); } - private IEnumerable> GetMeasurementWithRetry(Func func) + private Measurement[] GetMeasurementWithRetry(Func func) + { + if (!TryGetValueWithRetry(func, out double value)) + { + return Array.Empty>(); + } + + return new[] { new Measurement(value) }; + } + + private bool TryGetValueWithRetry(Func func, out T value) + where T : struct { + value = default; if (Volatile.Read(ref _measurementsUnavailable) == 1 && _timeProvider.GetUtcNow() - _lastFailure < _retryInterval) { - return Enumerable.Empty>(); + return false; } try { - double result = func(); - if (Volatile.Read(ref _measurementsUnavailable) == 1) - { - _ = Interlocked.Exchange(ref _measurementsUnavailable, 0); - } + value = func(); + _ = Interlocked.CompareExchange(ref _measurementsUnavailable, 0, 1); - return new[] { new Measurement(result) }; + return true; } catch (Exception ex) when ( ex is System.IO.FileNotFoundException || @@ -285,7 +299,7 @@ ex is System.IO.DirectoryNotFoundException || _lastFailure = _timeProvider.GetUtcNow(); _ = Interlocked.Exchange(ref _measurementsUnavailable, 1); - return Enumerable.Empty>(); + return false; } } @@ -293,4 +307,17 @@ ex is System.IO.DirectoryNotFoundException || // due to the fact that the calculation itself is not an atomic operation: private double CpuUtilizationRequest(double cpuRequest) => Math.Min(One, CpuUtilizationV2() / cpuRequest); private double CpuUtilizationLimit(double cpuLimit) => Math.Min(One, CpuUtilizationV2() / cpuLimit); + + private IEnumerable> GetCpuTime() + { + if (TryGetValueWithRetry(_parser.GetHostCpuUsageInNanoseconds, out long systemCpuTime)) + { + yield return new Measurement(systemCpuTime / NanosecondsInSecond, [new KeyValuePair("cpu.mode", "system")]); + } + + if (TryGetValueWithRetry(CpuUtilizationV2, out double userCpuTime)) + { + yield return new Measurement(userCpuTime, [new KeyValuePair("cpu.mode", "user")]); + } + } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs index 27156ea874e..ca6ceaff8bd 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; using System.Threading; @@ -17,6 +18,7 @@ internal sealed class WindowsContainerSnapshotProvider : ISnapshotProvider { private const double One = 1.0d; private const double Hundred = 100.0d; + private const double TicksPerSecondDouble = TimeSpan.TicksPerSecond; private readonly Lazy _memoryStatus; @@ -85,16 +87,16 @@ internal WindowsContainerSnapshotProvider( _timeProvider = timeProvider; - using var jobHandle = _createJobHandleObject(); + using IJobHandle jobHandle = _createJobHandleObject(); - var memoryLimitLong = GetMemoryLimit(jobHandle); + ulong memoryLimitLong = GetMemoryLimit(jobHandle); _memoryLimit = memoryLimitLong; _cpuLimit = GetCpuLimit(jobHandle, systemInfo); // CPU request (aka guaranteed CPU units) is not supported on Windows, so we set it to the same value as CPU limit (aka maximum CPU units). // Memory request (aka guaranteed memory) is not supported on Windows, so we set it to the same value as memory limit (aka maximum memory). - var cpuRequest = _cpuLimit; - var memoryRequest = memoryLimitLong; + double cpuRequest = _cpuLimit; + ulong memoryRequest = memoryLimitLong; Resources = new SystemResources(cpuRequest, _cpuLimit, memoryRequest, memoryLimitLong); _logger.SystemResourcesInfo(_cpuLimit, cpuRequest, memoryLimitLong, memoryRequest); @@ -110,10 +112,11 @@ internal WindowsContainerSnapshotProvider( // We don't dispose the meter because IMeterFactory handles that // An issue on analyzer side: https://github.com/dotnet/roslyn-analyzers/issues/6912 // Related documentation: https://github.com/dotnet/docs/pull/37170 - var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); + Meter meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope // Container based metrics: + _ = meter.CreateObservableCounter(name: ResourceUtilizationInstruments.ContainerCpuTime, observeValues: GetCpuTime, unit: "s", description: "CPU time used by the container."); _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: CpuPercentage); _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, observeValue: () => MemoryPercentage(() => _processInfo.GetMemoryUsage())); @@ -155,7 +158,7 @@ private static double GetCpuLimit(IJobHandle jobHandle, ISystemInfo systemInfo) cpuRatio = cpuLimit.CpuRate / CpuCycles; } - var systemInfoValue = systemInfo.GetSystemInfo(); + SYSTEM_INFO systemInfoValue = systemInfo.GetSystemInfo(); // Multiply the cpu ratio by the number of processors to get you the portion // of processors used from the system. @@ -172,7 +175,7 @@ private ulong GetMemoryLimit(IJobHandle jobHandle) if (memoryLimitInBytes <= 0) { - var memoryStatus = _memoryStatus.Value; + MEMORYSTATUSEX memoryStatus = _memoryStatus.Value; // Technically, the unconstrained limit is memoryStatus.TotalPageFile. // Leaving this at physical as it is more understandable to consumers. @@ -184,7 +187,7 @@ private ulong GetMemoryLimit(IJobHandle jobHandle) private double MemoryPercentage(Func getMemoryUsage) { - var now = _timeProvider.GetUtcNow(); + DateTimeOffset now = _timeProvider.GetUtcNow(); lock (_memoryLocker) { @@ -194,7 +197,7 @@ private double MemoryPercentage(Func getMemoryUsage) } } - var memoryUsage = getMemoryUsage(); + ulong memoryUsage = getMemoryUsage(); lock (_memoryLocker) { @@ -211,6 +214,17 @@ private double MemoryPercentage(Func getMemoryUsage) } } + private IEnumerable> GetCpuTime() + { + using IJobHandle jobHandle = _createJobHandleObject(); + var basicAccountingInfo = jobHandle.GetBasicAccountingInfo(); + + yield return new Measurement(basicAccountingInfo.TotalUserTime / TicksPerSecondDouble, + [new KeyValuePair("cpu.mode", "user")]); + yield return new Measurement(basicAccountingInfo.TotalKernelTime / TicksPerSecondDouble, + [new KeyValuePair("cpu.mode", "system")]); + } + private double CpuPercentage() { var now = _timeProvider.GetUtcNow(); diff --git a/src/Shared/Instruments/ResourceUtilizationInstruments.cs b/src/Shared/Instruments/ResourceUtilizationInstruments.cs index c0a230c84ea..835d3099782 100644 --- a/src/Shared/Instruments/ResourceUtilizationInstruments.cs +++ b/src/Shared/Instruments/ResourceUtilizationInstruments.cs @@ -18,6 +18,14 @@ internal static class ResourceUtilizationInstruments /// public const string MeterName = "Microsoft.Extensions.Diagnostics.ResourceMonitoring"; + /// + /// The name of an instrument to retrieve CPU time consumed by the specific container on all available CPU cores, measured in seconds. + /// + /// + /// The type of an instrument is . + /// + public const string ContainerCpuTime = "container.cpu.time"; + /// /// The name of an instrument to retrieve CPU limit consumption of all processes running inside a container or control group in range [0, 1]. /// diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs index 16ec64fa003..7d9b347a59d 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs @@ -500,6 +500,7 @@ public async Task TestCpuAndMemoryChecks_WithMetrics( accountingInfoAfter1Ms.TotalUserTime = (long)(utilization * 100); jobHandleMock.SetupSequence(j => j.GetBasicAccountingInfo()) .Returns(() => initialAccountingInfo) // this is called from the WindowsContainerSnapshotProvider's constructor + .Returns(() => initialAccountingInfo) // this is called from the WindowsContainerSnapshotProvider's GetCpuTime method .Returns(() => accountingInfoAfter1Ms); // this is called from the WindowsContainerSnapshotProvider's CpuPercentage method using var meter = new Meter("Microsoft.Extensions.Diagnostics.ResourceMonitoring"); diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs index b71ea1c47d2..0221efc27c9 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; @@ -209,6 +210,8 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou using var listener = new MeterListener(); var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var cpuUserTime = 0.0d; + var cpuKernelTime = 0.0d; var cpuFromGauge = 0.0d; var cpuLimitFromGauge = 0.0d; var cpuRequestFromGauge = 0.0d; @@ -219,8 +222,8 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou object? meterScope = null; listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.SetMeasurementEventCallback((m, f, tags, _) + => OnMeasurementReceived(m, f, tags, ref cpuUserTime, ref cpuKernelTime, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); listener.Start(); using var host = FakeHost.CreateBuilder() @@ -292,6 +295,8 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou using var listener = new MeterListener(); var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var cpuUserTime = 0.0d; + var cpuKernelTime = 0.0d; var cpuFromGauge = 0.0d; var cpuLimitFromGauge = 0.0d; var cpuRequestFromGauge = 0.0d; @@ -302,8 +307,8 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou object? meterScope = null; listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.SetMeasurementEventCallback((m, f, tags, _) + => OnMeasurementReceived(m, f, tags, ref cpuUserTime, ref cpuKernelTime, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); listener.Start(); using var host = FakeHost.CreateBuilder() @@ -362,10 +367,8 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou [ConditionalFact] [CombinatorialData] [OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] - public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgroupsv2_v2_Using_NrPeriods() + public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgroupsv2_Using_LinuxCalculationV2() { - var cpuRefresh = TimeSpan.FromMinutes(13); - var memoryRefresh = TimeSpan.FromMinutes(14); var fileSystem = new HardcodedValueFileSystem(new Dictionary { { new FileInfo("/proc/self/cgroup"), "0::/fakeslice"}, @@ -382,25 +385,34 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); var cpuFromGauge = 0.0d; var cpuLimitFromGauge = 0.0d; + var cpuUserTime = 0.0d; + var cpuKernelTime = 0.0d; var cpuRequestFromGauge = 0.0d; var memoryFromGauge = 0.0d; var memoryLimitFromGauge = 0.0d; - using var e = new ManualResetEventSlim(); object? meterScope = null; listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.SetMeasurementEventCallback((m, f, tags, _) + => OnMeasurementReceived(m, + f, + tags, + ref cpuUserTime, + ref cpuKernelTime, + ref cpuFromGauge, + ref cpuLimitFromGauge, + ref cpuRequestFromGauge, + ref memoryFromGauge, + ref memoryLimitFromGauge)); listener.Start(); - using var host = FakeHost.CreateBuilder() + using IHost host = FakeHost.CreateBuilder() .ConfigureServices(x => x.AddLogging() .AddSingleton(clock) .AddSingleton(new FakeUserHz(100)) .AddSingleton(fileSystem) - .AddSingleton(new GenericPublisher(_ => e.Set())) .AddResourceMonitoring(x => x.ConfigureMonitor(options => { options.UseLinuxCalculationV2 = true; @@ -409,15 +421,11 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou .Build(); meterScope = host.Services.GetRequiredService(); - var tracker = host.Services.GetService(); - Assert.NotNull(tracker); _ = host.RunAsync(); listener.RecordObservableInstruments(); - var utilization = tracker.GetUtilization(TimeSpan.FromSeconds(5)); - fileSystem.ReplaceFileContent(new FileInfo("/proc/stat"), "cpu 11 10 10 10 10 10 10 10 10 10"); fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/fakeslice/cpu.stat"), "usage_usec 1120000\nnr_periods 56"); fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/memory.current"), "524298"); @@ -426,14 +434,10 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou clock.Advance(TimeSpan.FromSeconds(6)); listener.RecordObservableInstruments(); - e.Wait(); - - utilization = tracker.GetUtilization(TimeSpan.FromSeconds(5)); - - var roundedCpuUsedPercentage = Math.Round(utilization.CpuUsedPercentage, 1); - Assert.Equal(42, Math.Round(cpuLimitFromGauge * 100)); Assert.Equal(83, Math.Round(cpuRequestFromGauge * 100)); + Assert.Equal(167, Math.Round(cpuUserTime * 100)); + Assert.Equal(81, Math.Round(cpuKernelTime * 100)); return Task.CompletedTask; } @@ -448,6 +452,7 @@ private static void OnInstrumentPublished(Instrument instrument, MeterListener m #pragma warning disable S1067 // Expressions should not be too complex if (instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization || instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization || + instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime || instrument.Name == ResourceUtilizationInstruments.ContainerCpuRequestUtilization || instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization || instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization) @@ -457,10 +462,18 @@ private static void OnInstrumentPublished(Instrument instrument, MeterListener m #pragma warning restore S1067 // Expressions should not be too complex } - private static void OnMeasurementReceived( - Instrument instrument, double value, - ref double cpuFromGauge, ref double cpuLimitFromGauge, ref double cpuRequestFromGauge, - ref double memoryFromGauge, ref double memoryLimitFromGauge) +#pragma warning disable S107 // Methods should not have too many parameters + private static void OnMeasurementReceived(Instrument instrument, + double value, + ReadOnlySpan> tags, + ref double cpuUserTime, + ref double cpuKernelTime, + ref double cpuFromGauge, + ref double cpuLimitFromGauge, + ref double cpuRequestFromGauge, + ref double memoryFromGauge, + ref double memoryLimitFromGauge) +#pragma warning restore S107 // Methods should not have too many parameters { if (instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization) { @@ -470,6 +483,18 @@ private static void OnMeasurementReceived( { memoryFromGauge = value; } + else if (instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime) + { + var tagsArray = tags.ToArray(); + if (tagsArray.Contains(new KeyValuePair("cpu.mode", "user"))) + { + cpuUserTime = value; + } + else if (tagsArray.Contains(new KeyValuePair("cpu.mode", "system"))) + { + cpuKernelTime = value; + } + } else if (instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization) { cpuLimitFromGauge = value; diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs index 57b92bcfa85..b34dfb1c258 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs @@ -259,7 +259,7 @@ public void Provider_Registers_Instruments_CgroupV2_WithoutHostCpu() listener.Start(); listener.RecordObservableInstruments(); - Assert.Equal(4, samples.Count); + Assert.Equal(6, samples.Count); Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization).value)); @@ -267,6 +267,9 @@ public void Provider_Registers_Instruments_CgroupV2_WithoutHostCpu() Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuRequestUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuRequestUtilization).value)); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime); + Assert.All(samples.Where(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime), item => double.IsNaN(item.value)); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization); Assert.Equal(1, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization).value); diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs index 08d3ecf6020..fc9113eacc9 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs @@ -190,6 +190,66 @@ public void GetSnapshot_With_JobMemoryLimit_Set_To_Zero_ProducesCorrectSnapshot( Assert.True(data.MemoryUsageInBytes > 0); } + [Fact] + public void SnapshotProvider_EmitsCpuTimeMetric() + { + // Simulating 10% CPU usage (2 CPUs, 2000 ticks initially, 4000 ticks after 1 ms): + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION updatedAccountingInfo = default; + updatedAccountingInfo.TotalKernelTime = 2500; + updatedAccountingInfo.TotalUserTime = 1500; + + _jobHandleMock.SetupSequence(j => j.GetBasicAccountingInfo()) + .Returns(_accountingInfo) + .Returns(_accountingInfo) + .Returns(updatedAccountingInfo) + .Returns(updatedAccountingInfo) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + _sysInfo.NumberOfProcessors = 2; + + var fakeClock = new FakeTimeProvider(); + using var meter = new Meter(nameof(SnapshotProvider_EmitsCpuMetrics)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + using var metricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerCpuTime, fakeClock); + + var options = new ResourceMonitoringOptions { CpuConsumptionRefreshInterval = TimeSpan.FromMilliseconds(2) }; + + var snapshotProvider = new WindowsContainerSnapshotProvider( + _memoryInfoMock.Object, + _systemInfoMock.Object, + _processInfoMock.Object, + _logger, + meterFactoryMock.Object, + () => _jobHandleMock.Object, + fakeClock, + options); + + // Step #0 - state in the beginning: + metricCollector.RecordObservableInstruments(); + var snapshot = metricCollector.GetMeasurementSnapshot(); + Assert.Equal(2, snapshot.Count); + Assert.Contains(_accountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + Assert.Contains(_accountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + + // Step #1 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(1)); + metricCollector.RecordObservableInstruments(); + snapshot = metricCollector.GetMeasurementSnapshot(); + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + + // Step #2 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(1)); + metricCollector.RecordObservableInstruments(); + snapshot = metricCollector.GetMeasurementSnapshot(); + + // CPU time should be the same as before, as we're not simulating any CPU usage: + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + } + [Theory] [InlineData(ResourceUtilizationInstruments.ProcessCpuUtilization, true)] [InlineData(ResourceUtilizationInstruments.ProcessCpuUtilization, false)] From e79652fed4000a954eb8349cd03361f4db205658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borja=20Dom=C3=ADnguez?= Date: Mon, 7 Jul 2025 13:23:10 +0200 Subject: [PATCH 19/71] Add netstandard2.0 compatibility to Microsoft.Extensions.Diagnostics.Testing and dependencies (#6219) --- .../Microsoft.Extensions.Diagnostics.Testing.csproj | 1 + .../Microsoft.Extensions.Telemetry.Abstractions.csproj | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj index 2ab592c4a4a..c38dcdea395 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Diagnostics.Testing + $(NetCoreTargetFrameworks);netstandard2.0;net462 Hand-crafted fakes to make telemetry-related testing easier. Telemetry $(PackageTags);Testing diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj index 816ad679585..a461593894e 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Telemetry + $(NetCoreTargetFrameworks);netstandard2.0;net462 Common abstractions for high-level telemetry primitives. Telemetry From 63ede029911898438a78e6bf141f61c38176240b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borja=20Dom=C3=ADnguez?= Date: Mon, 7 Jul 2025 14:10:05 +0200 Subject: [PATCH 20/71] Add netstandard2.0 compatibility to Microsoft.Extensions.Telemetry and dependencies (#6218) --- .../Microsoft.Extensions.AmbientMetadata.Application.csproj | 1 + ...oft.Extensions.DependencyInjection.AutoActivation.csproj | 1 + .../Microsoft.Extensions.Telemetry.csproj | 1 + .../Sampling/RandomProbabilisticSampler.cs | 6 +++--- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj index f631a4047bb..86f07dc205f 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj @@ -1,6 +1,7 @@ Microsoft.Extensions.AmbientMetadata + $(NetCoreTargetFrameworks);netstandard2.0;net462 Runtime information provider for application-level ambient metadata. Telemetry diff --git a/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj b/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj index 7d02c3f1e90..5dd62090e1e 100644 --- a/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj +++ b/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj @@ -1,6 +1,7 @@ Microsoft.Extensions.DependencyInjection + $(NetCoreTargetFrameworks);netstandard2.0;net462 Extensions to auto-activate registered singletons in the dependency injection system. Fundamentals diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj b/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj index 9dc4a11ca6c..8ff5676e349 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Diagnostics + $(NetCoreTargetFrameworks);netstandard2.0;net462 Provides canonical implementations of telemetry abstractions. Telemetry diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs index 6ba0a376c25..d809da8a2ad 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs @@ -3,7 +3,7 @@ using System; using System.Linq; -#if !NETFRAMEWORK +#if !NETFRAMEWORK && !NETSTANDARD using System.Security.Cryptography; #endif using Microsoft.Extensions.Logging; @@ -22,7 +22,7 @@ internal sealed class RandomProbabilisticSampler : LoggingSampler, IDisposable { internal RandomProbabilisticSamplerFilterRule[] LastKnownGoodSamplerRules; -#if NETFRAMEWORK +#if NETFRAMEWORK || NETSTANDARD private static readonly System.Threading.ThreadLocal _randomInstance = new(() => new Random()); #endif @@ -50,7 +50,7 @@ public override bool ShouldSample(in LogEntry logEntry) return true; } -#if NETFRAMEWORK +#if NETFRAMEWORK || NETSTANDARD return _randomInstance.Value!.Next(int.MaxValue) < int.MaxValue * probability; #else return RandomNumberGenerator.GetInt32(int.MaxValue) < int.MaxValue * probability; From abae3a9deda15b03d21c95a9e81631ec8b90d9cd Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Mon, 7 Jul 2025 20:59:59 +0300 Subject: [PATCH 21/71] AIFunctionFactory: add a flag for disabling return schema generation. (#6551) * AIFunctionFactory: add a flag for disabling return schema generation. * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs Co-authored-by: Stephen Toub * Address feedback --------- Co-authored-by: Stephen Toub --- .../Functions/AIFunctionFactory.cs | 5 +++-- .../Functions/AIFunctionFactoryOptions.cs | 13 +++++++++++++ .../Microsoft.Extensions.AI.Abstractions.json | 4 ++++ .../Functions/AIFunctionFactoryTest.cs | 16 ++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 5ad178e7bc8..0d7de9a341e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -615,7 +615,7 @@ public static ReflectionAIFunctionDescriptor GetOrCreate(MethodInfo method, AIFu serializerOptions.MakeReadOnly(); ConcurrentDictionary innerCache = _descriptorCache.GetOrCreateValue(serializerOptions); - DescriptorKey key = new(method, options.Name, options.Description, options.ConfigureParameterBinding, options.MarshalResult, schemaOptions); + DescriptorKey key = new(method, options.Name, options.Description, options.ConfigureParameterBinding, options.MarshalResult, options.ExcludeResultSchema, schemaOptions); if (innerCache.TryGetValue(key, out ReflectionAIFunctionDescriptor? descriptor)) { return descriptor; @@ -690,7 +690,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions Name = key.Name ?? GetFunctionName(key.Method); Description = key.Description ?? key.Method.GetCustomAttribute(inherit: true)?.Description ?? string.Empty; JsonSerializerOptions = serializerOptions; - ReturnJsonSchema = returnType is null ? null : AIJsonUtilities.CreateJsonSchema( + ReturnJsonSchema = returnType is null || key.ExcludeResultSchema ? null : AIJsonUtilities.CreateJsonSchema( returnType, description: key.Method.ReturnParameter.GetCustomAttribute(inherit: true)?.Description, serializerOptions: serializerOptions, @@ -1036,6 +1036,7 @@ private record struct DescriptorKey( string? Description, Func? GetBindParameterOptions, Func>? MarshalResult, + bool ExcludeResultSchema, AIJsonSchemaCreateOptions SchemaOptions); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs index e71a4687422..ebfffc34908 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs @@ -106,6 +106,19 @@ 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 . + /// + /// + /// + /// The default value is . + /// + /// + /// When set to , results in the produced to always be . + /// + /// + public bool ExcludeResultSchema { get; set; } + /// Provides configuration options produced by the delegate. public readonly record struct ParameterBindingOptions { 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 5e87edc01f9..b47765269af 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 @@ -303,6 +303,10 @@ "Member": "string? Microsoft.Extensions.AI.AIFunctionFactoryOptions.Description { get; set; }", "Stage": "Stable" }, + { + "Member": "bool Microsoft.Extensions.AI.AIFunctionFactoryOptions.ExcludeResultSchema { get; set; }", + "Stage": "Stable" + }, { "Member": "Microsoft.Extensions.AI.AIJsonSchemaCreateOptions? Microsoft.Extensions.AI.AIFunctionFactoryOptions.JsonSchemaCreateOptions { get; set; }", "Stage": "Stable" diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index afced22038f..8c0c7d057a6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -274,6 +274,7 @@ public void AIFunctionFactoryOptions_DefaultValues() Assert.Null(options.SerializerOptions); Assert.Null(options.JsonSchemaCreateOptions); Assert.Null(options.ConfigureParameterBinding); + Assert.False(options.ExcludeResultSchema); } [Fact] @@ -300,6 +301,21 @@ public async Task AIFunctionFactoryOptions_SupportsSkippingParameters() Assert.Contains("test42", result.ToString()); } + [Fact] + public void AIFunctionFactoryOptions_SupportsSkippingReturnSchema() + { + AIFunction func = AIFunctionFactory.Create( + (string firstParameter, int secondParameter) => firstParameter + secondParameter, + new() + { + ExcludeResultSchema = true, + }); + + Assert.Contains("firstParameter", func.JsonSchema.ToString()); + Assert.Contains("secondParameter", func.JsonSchema.ToString()); + Assert.Null(func.ReturnJsonSchema); + } + [Fact] public async Task AIFunctionArguments_SatisfiesParameters() { From eb845248ca8e31de47939df785506bcb7030ac8d Mon Sep 17 00:00:00 2001 From: Pent Ploompuu Date: Tue, 8 Jul 2025 09:45:50 +0300 Subject: [PATCH 22/71] Simplify Http.Diagnostics (#6174) --- .../DownstreamDependencyMetadataManager.cs | 33 +++++--- .../HttpClientLatencyTelemetryExtensions.cs | 4 +- .../Internal/HttpClientLatencyContext.cs | 5 -- .../Internal/HttpLatencyTelemetryHandler.cs | 18 ++--- .../Internal/HttpRequestLatencyListener.cs | 81 +++++-------------- .../Logging/Internal/HttpClientLogger.cs | 8 +- .../Logging/Internal/HttpRequestBodyReader.cs | 37 ++------- .../Internal/HttpResponseBodyReader.cs | 34 +++----- ...crosoft.Extensions.Http.Diagnostics.csproj | 5 +- ...icrosoft.Extensions.Http.Resilience.csproj | 4 - .../HttpRequestLatencyListenerTest.cs | 30 ++----- 11 files changed, 78 insertions(+), 181 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs index fbe54413cbf..c8b03913533 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs @@ -100,31 +100,40 @@ private static void AddRouteToTrie(RequestMetadata routeMetadata, Dictionary requestRouteAsSpan = routeMetadata.RequestRoute.AsSpan(); - - if (requestRouteAsSpan.Length > 0) + var route = routeMetadata.RequestRoute; + if (!string.IsNullOrEmpty(route)) { - if (requestRouteAsSpan[0] != '/') + var routeSpan = route.AsSpan(); + if (routeSpan.StartsWith("//".AsSpan())) { - requestRouteAsSpan = $"/{routeMetadata.RequestRoute}".AsSpan(); + routeSpan = routeSpan.Slice(1); } - else if (requestRouteAsSpan.StartsWith("//".AsSpan(), StringComparison.OrdinalIgnoreCase)) + + if (routeSpan.Length > 1 && routeSpan[routeSpan.Length - 1] == '/') { - requestRouteAsSpan = requestRouteAsSpan.Slice(1); + routeSpan = routeSpan.Slice(0, routeSpan.Length - 1); } - if (requestRouteAsSpan.Length > 1 && requestRouteAsSpan[requestRouteAsSpan.Length - 1] == '/') + if (routeSpan[0] != '/') { - requestRouteAsSpan = requestRouteAsSpan.Slice(0, requestRouteAsSpan.Length - 1); +#if NET + route = $"/{routeSpan}"; +#else + route = $"/{routeSpan.ToString()}"; +#endif } + else if (routeSpan.Length != route.Length) + { + route = routeSpan.ToString(); + } + + route = _routeRegex.Replace(route, "*").ToUpperInvariant(); } else { - requestRouteAsSpan = "/".AsSpan(); + route = "/"; } - var route = _routeRegex.Replace(requestRouteAsSpan.ToString(), "*"); - route = route.ToUpperInvariant(); for (int i = 0; i < route.Length; i++) { char ch = route[i]; diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs index 309f24e1f2d..fa77360a8c1 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs @@ -30,8 +30,8 @@ public static IServiceCollection AddHttpClientLatencyTelemetry(this IServiceColl _ = services.RegisterCheckpointNames(HttpCheckpoints.Checkpoints); _ = services.AddOptions(); - _ = services.AddActivatedSingleton(); - _ = services.AddActivatedSingleton(); + _ = services.AddSingleton(); + _ = services.AddSingleton(); _ = services.AddTransient(); _ = services.AddHttpClientLogEnricher(); diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs index 338326cc690..d5b5f0a40fb 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs @@ -19,9 +19,4 @@ public void Set(ILatencyContext context) { _latencyContext.Value = context; } - - public void Unset() - { - _latencyContext.Value = null; - } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs index d0d38a875ec..3180cb890c6 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.Diagnostics.Latency; using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Extensions.Options; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Http.Latency.Internal; @@ -24,17 +23,14 @@ internal sealed class HttpLatencyTelemetryHandler : DelegatingHandler private readonly string _applicationName; public HttpLatencyTelemetryHandler(HttpRequestLatencyListener latencyListener, ILatencyContextTokenIssuer tokenIssuer, ILatencyContextProvider latencyContextProvider, - IOptions options, IOptions appMetdata) + IOptions options, IOptions appMetadata) { - var appMetadata = Throw.IfMemberNull(appMetdata, appMetdata.Value); - var telemetryOptions = Throw.IfMemberNull(options, options.Value); - _latencyListener = latencyListener; _latencyContextProvider = latencyContextProvider; _handlerStart = tokenIssuer.GetCheckpointToken(HttpCheckpoints.HandlerRequestStart); - _applicationName = appMetdata.Value.ApplicationName; + _applicationName = appMetadata.Value.ApplicationName; - if (telemetryOptions.EnableDetailedLatencyBreakdown) + if (options.Value.EnableDetailedLatencyBreakdown) { _latencyListener.Enable(); } @@ -46,12 +42,8 @@ protected async override Task SendAsync(HttpRequestMessage context.AddCheckpoint(_handlerStart); _latencyListener.LatencyContext.Set(context); - request.Headers.Add(TelemetryConstants.ClientApplicationNameHeader, _applicationName); - - var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - - _latencyListener.LatencyContext.Unset(); + _ = request.Headers.TryAddWithoutValidation(TelemetryConstants.ClientApplicationNameHeader, _applicationName); - return response; + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs index 744d7a5ddd3..214d877b92e 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs @@ -1,8 +1,6 @@ // 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.Concurrent; using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.Tracing; @@ -17,17 +15,10 @@ internal sealed class HttpRequestLatencyListener : EventListener private const string HttpProviderName = "System.Net.Http"; private const string NameResolutionProivderName = "System.Net.NameResolution"; - private readonly ConcurrentDictionary _eventSources = new() - { - [SocketProviderName] = null, - [HttpProviderName] = null, - [NameResolutionProivderName] = null - }; + private readonly FrozenDictionary> _eventToTokenMap; internal HttpClientLatencyContext LatencyContext { get; } - private readonly EventToCheckpointToken _eventToCheckpointToken; - private int _enabled; internal bool Enabled => _enabled == 1; @@ -35,39 +26,32 @@ internal sealed class HttpRequestLatencyListener : EventListener public HttpRequestLatencyListener(HttpClientLatencyContext latencyContext, ILatencyContextTokenIssuer tokenIssuer) { LatencyContext = latencyContext; - _eventToCheckpointToken = new(tokenIssuer); + _eventToTokenMap = EventToCheckpointToken.Build(tokenIssuer); } public void Enable() { if (Interlocked.CompareExchange(ref _enabled, 1, 0) == 0) { - foreach (var eventSource in _eventSources) - { - if (eventSource.Value != null) - { - EnableEventSource(eventSource.Value); - } - } + // process already existing listeners once again + EventSourceCreated += (_, args) => OnEventSourceCreated(args.EventSource!); } } internal void OnEventWritten(string eventSourceName, string? eventName) { // If event of interest, add a checkpoint for it. - CheckpointToken? token = _eventToCheckpointToken.GetCheckpointToken(eventSourceName, eventName); - if (token.HasValue) + if (eventName != null && _eventToTokenMap[eventSourceName].TryGetValue(eventName, out var token)) { - LatencyContext.Get()?.AddCheckpoint(token.Value); + LatencyContext.Get()?.AddCheckpoint(token); } } internal void OnEventSourceCreated(string eventSourceName, EventSource eventSource) { - if (_eventSources.ContainsKey(eventSourceName)) + if (Enabled && _eventToTokenMap.ContainsKey(eventSourceName)) { - _eventSources[eventSourceName] = eventSource; - EnableEventSource(eventSource); + EnableEvents(eventSource, EventLevel.Informational); } } @@ -81,15 +65,7 @@ protected override void OnEventWritten(EventWrittenEventArgs eventData) OnEventWritten(eventData.EventSource.Name, eventData.EventName); } - private void EnableEventSource(EventSource eventSource) - { - if (Enabled && !eventSource.IsEnabled()) - { - EnableEvents(eventSource, EventLevel.Informational); - } - } - - private sealed class EventToCheckpointToken + private static class EventToCheckpointToken { private static readonly Dictionary _socketMap = new() { @@ -117,47 +93,32 @@ private sealed class EventToCheckpointToken { "ResponseContentStop", HttpCheckpoints.ResponseContentEnd } }; - private readonly FrozenDictionary> _eventToTokenMap; - - public EventToCheckpointToken(ILatencyContextTokenIssuer tokenIssuer) + public static FrozenDictionary> Build(ILatencyContextTokenIssuer tokenIssuer) { Dictionary socket = []; - foreach (string key in _socketMap.Keys) + foreach (var kv in _socketMap) { - socket[key] = tokenIssuer.GetCheckpointToken(_socketMap[key]); + socket[kv.Key] = tokenIssuer.GetCheckpointToken(kv.Value); } Dictionary nameResolution = []; - foreach (string key in _nameResolutionMap.Keys) + foreach (var kv in _nameResolutionMap) { - nameResolution[key] = tokenIssuer.GetCheckpointToken(_nameResolutionMap[key]); + nameResolution[kv.Key] = tokenIssuer.GetCheckpointToken(kv.Value); } Dictionary http = []; - foreach (string key in _httpMap.Keys) + foreach (var kv in _httpMap) { - http[key] = tokenIssuer.GetCheckpointToken(_httpMap[key]); + http[kv.Key] = tokenIssuer.GetCheckpointToken(kv.Value); } - _eventToTokenMap = new Dictionary> + return new Dictionary> { - { SocketProviderName, socket.ToFrozenDictionary(StringComparer.Ordinal) }, - { NameResolutionProivderName, nameResolution.ToFrozenDictionary(StringComparer.Ordinal) }, - { HttpProviderName, http.ToFrozenDictionary(StringComparer.Ordinal) } - }.ToFrozenDictionary(StringComparer.Ordinal); - } - - public CheckpointToken? GetCheckpointToken(string eventSourceName, string? eventName) - { - if (eventName != null && _eventToTokenMap.TryGetValue(eventSourceName, out var events)) - { - if (events.TryGetValue(eventName, out var token)) - { - return token; - } - } - - return null; + { SocketProviderName, socket.ToFrozenDictionary() }, + { NameResolutionProivderName, nameResolution.ToFrozenDictionary() }, + { HttpProviderName, http.ToFrozenDictionary() } + }.ToFrozenDictionary(); } } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs index bbf8095e384..0e1aeb6d565 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs @@ -109,22 +109,22 @@ internal HttpClientLogger( } } - public async ValueTask LogRequestStopAsync( + public ValueTask LogRequestStopAsync( object? context, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed, CancellationToken cancellationToken = default) - => await LogResponseAsync(context, request, response, null, elapsed, cancellationToken).ConfigureAwait(false); + => LogResponseAsync(context, request, response, null, elapsed, cancellationToken); - public async ValueTask LogRequestFailedAsync( + public ValueTask LogRequestFailedAsync( object? context, HttpRequestMessage request, HttpResponseMessage? response, Exception exception, TimeSpan elapsed, CancellationToken cancellationToken = default) - => await LogResponseAsync(context, request, response, exception, elapsed, cancellationToken).ConfigureAwait(false); + => LogResponseAsync(context, request, response, exception, elapsed, cancellationToken); public object? LogRequestStart(HttpRequestMessage request) => throw new NotSupportedException(SyncLoggingExceptionMessage); diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs index ed5a3c3f33d..c7abf7f0df8 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs @@ -2,21 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Collections.Frozen; using System.IO; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; -#if NETCOREAPP3_1_OR_GREATER -using Microsoft.Extensions.ObjectPool; -#endif using Microsoft.Shared.Diagnostics; -#if NETCOREAPP3_1_OR_GREATER -using Microsoft.Shared.Pools; -#else -using System.Buffers; -#endif namespace Microsoft.Extensions.Http.Logging.Internal; @@ -27,9 +20,6 @@ internal sealed class HttpRequestBodyReader /// internal readonly TimeSpan RequestReadTimeout; -#if NETCOREAPP3_1_OR_GREATER - private static readonly ObjectPool> _bufferWriterPool = BufferWriterPool.SharedBufferWriterPool; -#endif private readonly FrozenSet _readableRequestContentTypes; private readonly int _requestReadLimit; @@ -93,33 +83,20 @@ private static async ValueTask ReadFromStreamAsync(HttpRequestMessage re #endif var readLimit = Math.Min(readSizeLimit, (int)streamToReadFrom.Length); -#if NETCOREAPP3_1_OR_GREATER - var bufferWriter = _bufferWriterPool.Get(); - try - { - var memory = bufferWriter.GetMemory(readLimit).Slice(0, readLimit); - var charsWritten = await streamToReadFrom.ReadAsync(memory, cancellationToken).ConfigureAwait(false); - - return Encoding.UTF8.GetString(memory[..charsWritten].Span); - } - finally - { - _bufferWriterPool.Return(bufferWriter); - streamToReadFrom.Seek(0, SeekOrigin.Begin); - } - -#else var buffer = ArrayPool.Shared.Rent(readLimit); try { - _ = await streamToReadFrom.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); - return Encoding.UTF8.GetString(buffer.AsSpan(0, readLimit).ToArray()); +#if NET + var read = await streamToReadFrom.ReadAsync(buffer.AsMemory(0, readLimit), cancellationToken).ConfigureAwait(false); +#else + var read = await streamToReadFrom.ReadAsync(buffer, 0, readLimit, cancellationToken).ConfigureAwait(false); +#endif + return Encoding.UTF8.GetString(buffer, 0, read); } finally { ArrayPool.Shared.Return(buffer); streamToReadFrom.Seek(0, SeekOrigin.Begin); } -#endif } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs index 0c5b6a672b1..8022ee74197 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs @@ -112,10 +112,7 @@ private static async ValueTask ReadFromStreamAsync(HttpResponseMessage r // if stream is not seekable we need to write the rest of the stream to the pipe // and create a new response content with the pipe reader as stream - _ = Task.Run(async () => - { - await WriteStreamToPipeAsync(streamToReadFrom, pipe.Writer, cancellationToken).ConfigureAwait(false); - }, CancellationToken.None); + _ = WriteStreamToPipeAsync(streamToReadFrom, pipe.Writer, cancellationToken); // use the pipe reader as stream for the new content var newContent = new StreamContent(pipe.Reader.AsStream()); @@ -130,41 +127,29 @@ private static async ValueTask ReadFromStreamAsync(HttpResponseMessage r } #if NET6_0_OR_GREATER - private static async Task BufferStreamAndWriteToPipeAsync(Stream stream, PipeWriter writer, int bufferSize, CancellationToken cancellationToken) + private static async ValueTask BufferStreamAndWriteToPipeAsync(Stream stream, PipeWriter writer, int bufferSize, CancellationToken cancellationToken) { Memory memory = writer.GetMemory(bufferSize)[..bufferSize]; -#if NET8_0_OR_GREATER int bytesRead = await stream.ReadAtLeastAsync(memory, bufferSize, false, cancellationToken).ConfigureAwait(false); -#else - int bytesRead = 0; - while (bytesRead < bufferSize) - { - int read = await stream.ReadAsync(memory.Slice(bytesRead), cancellationToken).ConfigureAwait(false); - if (read == 0) - { - break; - } - - bytesRead += read; - } -#endif - if (bytesRead == 0) { return string.Empty; } + var res = Encoding.UTF8.GetString(memory.Span[..bytesRead]); writer.Advance(bytesRead); - return Encoding.UTF8.GetString(memory[..bytesRead].Span); + return res; } private static async Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + while (true) { - Memory memory = writer.GetMemory(ChunkSize)[..ChunkSize]; + Memory memory = writer.GetMemory(ChunkSize); int bytesRead = await stream.ReadAsync(memory, cancellationToken).ConfigureAwait(false); if (bytesRead == 0) @@ -216,7 +201,10 @@ private static async Task BufferStreamAndWriteToPipeAsync(Stream stream, return sb.ToString(); } - private static async Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) + private static Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) + => Task.Run(() => WriteStreamToPipeImplAsync(stream, writer, cancellationToken), CancellationToken.None); + + private static async Task WriteStreamToPipeImplAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) { while (true) { diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj index cc00c907ded..53cd4c1e0d2 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj @@ -31,13 +31,12 @@ - - - + + diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj index 8d280d747cb..8ab678e8eb0 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj @@ -36,10 +36,6 @@ - - - - diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs index 3675bc3901a..fa91daa1c3e 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs @@ -16,10 +16,9 @@ public void HttpClientLatencyContext_Set_BasicFunction() { var lc = HttpMockProvider.GetLatencyContext(); var context = new HttpClientLatencyContext(); + Assert.Null(context.Get()); context.Set(lc.Object); Assert.Equal(context.Get(), lc.Object); - context.Unset(); - Assert.Null(context.Get()); } [Fact] @@ -115,28 +114,6 @@ public void HttpRequestLatencyListener_OnEventSourceCreated_HttpSources() Assert.True(esNameRes.IsEnabled()); } - [Fact] - public void HttpRequestLatencyListener_OnEventSourceCreated_Twice() - { - var lcti = HttpMockProvider.GetTokenIssuer(); - var lc = HttpMockProvider.GetLatencyContext(); - var context = new HttpClientLatencyContext(); - context.Set(lc.Object); - - using var listener = HttpMockProvider.GetListener(context, lcti.Object); - Assert.NotNull(listener); - listener.Enable(); - - using var esSockets = new HttpMockProvider.SockeyMockEventSource(); - listener.OnEventSourceCreated("System.Net.Sockets", esSockets); - Assert.Equal(1, esSockets.OnEventInvoked); - Assert.True(esSockets.IsEnabled()); - - listener.OnEventSourceCreated("System.Net.Sockets", esSockets); - Assert.Equal(1, esSockets.OnEventInvoked); - Assert.True(esSockets.IsEnabled()); - } - [Fact] public void HttpRequestLatencyListener_OnEventWritten_DoesNotAddCheckpoints_NonHttp() { @@ -146,15 +123,18 @@ public void HttpRequestLatencyListener_OnEventWritten_DoesNotAddCheckpoints_NonH context.Set(lc.Object); using var listener = HttpMockProvider.GetListener(context, lcti.Object); + listener.Enable(); var events = new[] { "ConnectionEstablished", "RequestLeftQueue", "ResolutionStop", "ConnectStart", "New" }; + using var es = new HttpMockProvider.MockEventSource(); + listener.OnEventSourceCreated("System.Net", es); for (int i = 0; i < events.Length; i++) { - listener.OnEventWritten("System.Net", events[i]); + es.Write(events[i]); } lc.Verify(a => a.AddCheckpoint(It.IsAny()), Times.Never); From 23c62b80989f7baf55dc394829895b636f960fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borja=20Dom=C3=ADnguez?= Date: Tue, 8 Jul 2025 11:04:13 +0200 Subject: [PATCH 23/71] Add netstandard2.0 compatibility to Microsoft.Extensions.Http.Resilience and dependencies (#6582) --- ...t.Extensions.Diagnostics.ExceptionSummarization.csproj | 1 + .../Latency/Internal/HttpRequestLatencyListener.cs | 8 ++++++++ .../Microsoft.Extensions.Http.Diagnostics.csproj | 1 + .../Microsoft.Extensions.Http.Resilience.csproj | 3 ++- .../Microsoft.Extensions.Resilience.csproj | 1 + 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj index 0073e039f8f..4b4993a7b99 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj @@ -3,6 +3,7 @@ Microsoft.Extensions.Diagnostics.ExceptionSummarization Lets you retrieve exception summary information. Telemetry + $(NetCoreTargetFrameworks);netstandard2.0;net462 diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs index 214d877b92e..5d7238ed8a3 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs @@ -33,8 +33,16 @@ public void Enable() { if (Interlocked.CompareExchange(ref _enabled, 1, 0) == 0) { +#if NETSTANDARD + foreach (var eventSource in EventSource.GetSources()) + { + OnEventSourceCreated(eventSource.Name, eventSource); + } +#else // process already existing listeners once again EventSourceCreated += (_, args) => OnEventSourceCreated(args.EventSource!); +#endif + } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj index 53cd4c1e0d2..bc7b8f4a6fe 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj @@ -3,6 +3,7 @@ Microsoft.Extensions.Http.Diagnostics Telemetry support for HTTP Client. Telemetry + $(NetCoreTargetFrameworks);netstandard2.0;net462 diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj index 8ab678e8eb0..1d1a3684305 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Http.Resilience + $(NetCoreTargetFrameworks);netstandard2.0;net462 Resilience mechanisms for HttpClient. Resilience @@ -28,7 +29,7 @@ - $(NoWarn);LA0006 + $(NoWarn);LA0006 diff --git a/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj b/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj index ebd63256933..519b6632d07 100644 --- a/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj +++ b/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Resilience + $(NetCoreTargetFrameworks);netstandard2.0;net462 Extensions to the Polly libraries to enrich telemetry with metadata and exception summaries. Resilience From 294a33df47d48f39038ebed1c82a8a50e0345e50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Tue, 8 Jul 2025 12:20:46 +0200 Subject: [PATCH 24/71] Ignore null loggers returned by LogProviders in ExtendedLoggerFactory (#6585) --- .../Logging/ExtendedLoggerFactory.cs | 14 +++++++++++--- .../Logging/ExtendedLoggerFactoryTests.cs | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs index 44ec71b6f68..24d4680a3ad 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Diagnostics.Buffering; #endif using Microsoft.Extensions.Diagnostics.Enrichment; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Shared.Diagnostics; @@ -223,13 +224,20 @@ private void AddProviderRegistration(ILoggerProvider provider, bool dispose) private LoggerInformation[] CreateLoggers(string categoryName) { - var loggers = new LoggerInformation[_providerRegistrations.Count]; + var loggers = new List(_providerRegistrations.Count); for (int i = 0; i < _providerRegistrations.Count; i++) { - loggers[i] = new LoggerInformation(_providerRegistrations[i].Provider, categoryName); + var loggerInformation = new LoggerInformation(_providerRegistrations[i].Provider, categoryName); + + // We do not need to check for NullLogger.Instance as no provider would reasonably return it (the handling is at + // outer loggers level, not inner level loggers in Logger/LoggerProvider). + if (loggerInformation.Logger != NullLogger.Instance) + { + loggers.Add(loggerInformation); + } } - return loggers; + return loggers.ToArray(); } private (MessageLogger[] messageLoggers, ScopeLogger[] scopeLoggers) ApplyFilters(LoggerInformation[] loggers) diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs index 3d02c9ae578..f35dc88560a 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs @@ -8,6 +8,7 @@ using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using Moq; using Xunit; @@ -536,6 +537,20 @@ public static void CreateDisposeDisposesInnerServiceProvider() Assert.True(disposed); } + [Fact] + public static void NullLoggerByProviderIsIgnored() + { + using var factory = Utils.CreateLoggerFactory(builder => + { + builder.AddProvider(NullLoggerProvider.Instance); + builder.AddProvider(new Provider()); + }); + var logger1 = (ExtendedLogger)factory.CreateLogger("C1"); + Assert.Single(logger1.MessageLoggers); + + logger1.LogInformation("This should not throw an exception."); + } + private class InternalScopeLoggerProvider : ILoggerProvider, ILogger { private IExternalScopeProvider _scopeProvider = new LoggerExternalScopeProvider(); From e6b555c147e47c7b848cae4aa708468deb28b889 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 8 Jul 2025 16:46:11 -0700 Subject: [PATCH 25/71] Fix template tests --- src/ProjectTemplates/GeneratedContent.targets | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index dfe93dbc5a8..e37794c3c0e 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -21,9 +21,9 @@ - Use specific version numbers to pin to already-released packages --> - $(Version) - $(Version) - $(Version) + 9.7.0 + 9.7.0-preview.1.25356.2 + 9.7.0 From d651ccc5fbae3d22812dc1f75ad005be7d1e8415 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 11 Jul 2025 09:56:34 -0400 Subject: [PATCH 26/71] Bump FunctionInvokingChatClient.MaximumIterationsPerRequest from 10 to 40 (#6599) Folks are bumping up against the arbitrary limit of 10, as various modern models are super chatty with tools. While we need a limit to avoid runaway execution, we can make it much higher. --- .../ChatCompletion/FunctionInvokingChatClient.cs | 4 ++-- .../ChatCompletion/FunctionInvokingChatClientTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 6b1d3b3e905..0a8673dc91d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -61,7 +61,7 @@ public partial class FunctionInvokingChatClient : DelegatingChatClient private readonly ActivitySource? _activitySource; /// Maximum number of roundtrips allowed to the inner client. - private int _maximumIterationsPerRequest = 10; + private int _maximumIterationsPerRequest = 40; // arbitrary default to prevent runaway execution /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. private int _maximumConsecutiveErrorsPerRequest = 3; @@ -142,7 +142,7 @@ public static FunctionInvocationContext? CurrentContext /// /// /// The maximum number of iterations per request. - /// The default value is 10. + /// The default value is 40. /// /// /// diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 1379cef8bf0..b4ce2f1546c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -36,7 +36,7 @@ public void Ctor_HasExpectedDefaults() Assert.False(client.AllowConcurrentInvocation); Assert.False(client.IncludeDetailedErrors); - Assert.Equal(10, client.MaximumIterationsPerRequest); + Assert.Equal(40, client.MaximumIterationsPerRequest); Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); Assert.Null(client.FunctionInvoker); } @@ -55,7 +55,7 @@ public void Properties_Roundtrip() client.IncludeDetailedErrors = true; Assert.True(client.IncludeDetailedErrors); - Assert.Equal(10, client.MaximumIterationsPerRequest); + Assert.Equal(40, client.MaximumIterationsPerRequest); client.MaximumIterationsPerRequest = 5; Assert.Equal(5, client.MaximumIterationsPerRequest); From cc0d010d068ddab0c5951b6cfc5793d379c0532c Mon Sep 17 00:00:00 2001 From: Iliar Turdushev Date: Fri, 11 Jul 2025 15:59:08 +0200 Subject: [PATCH 27/71] Fixes #6598 (#6600) Removes Conditional attribute from the definition of LogProperties and LogPropertyIgnore attributes --- .../Logging/LogPropertiesAttribute.cs | 2 -- .../Logging/LogPropertyIgnoreAttribute.cs | 2 -- .../Generated/LogPropertiesTests.cs | 22 +++++++++++++++++++ ...crosoft.Gen.Logging.Generated.Tests.csproj | 1 + .../HelperLibrary/FieldToLog.cs | 10 +++++++++ ...Microsoft.Gen.Logging.HelperLibrary.csproj | 15 +++++++++++++ .../HelperLibrary/ObjectToLog.cs | 17 ++++++++++++++ .../TestClasses/LogPropertiesExtensions.cs | 4 ++++ .../Unit/EmitterTests.cs | 1 + .../Microsoft.Gen.Logging.Unit.Tests.csproj | 1 + 10 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 test/Generators/Microsoft.Gen.Logging/HelperLibrary/FieldToLog.cs create mode 100644 test/Generators/Microsoft.Gen.Logging/HelperLibrary/Microsoft.Gen.Logging.HelperLibrary.csproj create mode 100644 test/Generators/Microsoft.Gen.Logging/HelperLibrary/ObjectToLog.cs diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs index c2d6a65cd38..ff503f977be 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; @@ -14,7 +13,6 @@ namespace Microsoft.Extensions.Logging; /// /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] -[Conditional("CODE_GENERATION_ATTRIBUTES")] public sealed class LogPropertiesAttribute : Attribute { /// diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs index 954fcdeddb3..4911a6416db 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics; using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Logging; @@ -12,7 +11,6 @@ namespace Microsoft.Extensions.Logging; /// /// . [AttributeUsage(AttributeTargets.Property)] -[Conditional("CODE_GENERATION_ATTRIBUTES")] public sealed class LogPropertyIgnoreAttribute : Attribute { } diff --git a/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs b/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs index a8c5d752fc9..589f237d2cc 100644 --- a/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs +++ b/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs @@ -587,4 +587,26 @@ public void LogPropertiesReadonlyRecordStructArgument() latestRecord.StructuredState.Should().NotBeNull().And.Equal(expectedState); } + + [Fact] + public void LogPropertiesCorrectlyLogsObjectFromAnotherAssembly() + { + LogObjectFromAnotherAssembly(_logger, new ObjectToLog + { + PropertyToIgnore = "Foo", + PropertyToLog = "Bar", + FieldToLog = new FieldToLog { Name = "Fizz", Value = "Buzz" } + }); + + Assert.Equal(1, _logger.Collector.Count); + + var state = _logger.Collector.LatestRecord.StructuredState! + .ToDictionary(p => p.Key, p => p.Value); + + Assert.Equal(4, state.Count); + Assert.Contains("{OriginalFormat}", state); + Assert.Contains("logObject.PropertyToLog", state); + Assert.Contains("logObject.FieldToLog.Name", state); + Assert.Contains("logObject.FieldToLog.Value", state); + } } diff --git a/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj b/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj index 84621f147e7..d4a72e9e371 100644 --- a/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj +++ b/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj @@ -24,5 +24,6 @@ + diff --git a/test/Generators/Microsoft.Gen.Logging/HelperLibrary/FieldToLog.cs b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/FieldToLog.cs new file mode 100644 index 00000000000..6eb8cc08d5e --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/FieldToLog.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Gen.Logging.Test; + +public class FieldToLog +{ + public string? Name { get; set; } + public string? Value { get; set; } +} diff --git a/test/Generators/Microsoft.Gen.Logging/HelperLibrary/Microsoft.Gen.Logging.HelperLibrary.csproj b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/Microsoft.Gen.Logging.HelperLibrary.csproj new file mode 100644 index 00000000000..0f3e6d3bedf --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/Microsoft.Gen.Logging.HelperLibrary.csproj @@ -0,0 +1,15 @@ + + + Microsoft.Gen.Logging.Test + Test classes for Microsoft.Gen.Logging.Generated.Tests. + + + + $(TestNetCoreTargetFrameworks) + $(TestNetCoreTargetFrameworks)$(ConditionalNet462) + + + + + + diff --git a/test/Generators/Microsoft.Gen.Logging/HelperLibrary/ObjectToLog.cs b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/ObjectToLog.cs new file mode 100644 index 00000000000..c8e071ce6d9 --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/ObjectToLog.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Gen.Logging.Test; + +public class ObjectToLog +{ + [LogPropertyIgnore] + public string? PropertyToIgnore { get; set; } + + public string? PropertyToLog { get; set; } + + [LogProperties] + public FieldToLog? FieldToLog { get; set; } +} diff --git a/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs b/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs index d2b9c05b05b..013f8d85956 100644 --- a/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs +++ b/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using Microsoft.Extensions.Logging; +using Microsoft.Gen.Logging.Test; namespace TestClasses { @@ -244,5 +245,8 @@ public override string ToString() [LoggerMessage(6, LogLevel.Information, "Testing interface-typed argument here...")] public static partial void LogMethodInterfaceArg(ILogger logger, [LogProperties] IMyInterface complexParam); + + [LoggerMessage(7, LogLevel.Information, "Testing logging a complex object residing in another assembly...")] + public static partial void LogObjectFromAnotherAssembly(ILogger logger, [LogProperties] ObjectToLog logObject); } } diff --git a/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs b/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs index 58012c4915b..a53f1711bd3 100644 --- a/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs +++ b/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs @@ -45,6 +45,7 @@ public async Task TestEmitter() Assembly.GetAssembly(typeof(IRedactorProvider))!, Assembly.GetAssembly(typeof(PrivateDataAttribute))!, Assembly.GetAssembly(typeof(BigInteger))!, + Assembly.GetAssembly(typeof(ObjectToLog))! }, sources, symbols) diff --git a/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj b/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj index e566a5dbe9f..9090a895c67 100644 --- a/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj +++ b/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj @@ -20,6 +20,7 @@ + From 194d9dbff59c5ea7aeea295dce8f1affb75ee31f Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 11 Jul 2025 17:27:11 -0400 Subject: [PATCH 28/71] Expose M.E.AI.OpenAI input message conversions (#6601) Internally we have helpers that convert from M.E.AI chat messages to the various OpenAI object models. To ease interop when a developer gets M.E.AI messages from another library and then wants to submit them on their own to OpenAI, this just exposes those helpers publicly. --- .../OpenAIChatClient.cs | 10 +- .../OpenAIClientExtensions.cs | 12 ++ .../OpenAIResponseChatClient.cs | 4 +- .../OpenAIAIFunctionConversionTests.cs | 77 -------- .../OpenAIConversionTests.cs | 186 ++++++++++++++++++ 5 files changed, 205 insertions(+), 84 deletions(-) delete mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 3be0a1cc1ee..394fccad1b6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -70,7 +70,7 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. @@ -85,7 +85,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. @@ -115,7 +115,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op } /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. - private static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, ChatOptions? chatOptions, JsonSerializerOptions jsonOptions) + internal static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, ChatOptions? chatOptions) { // Maps all of the M.E.AI types to the corresponding OpenAI types. // Unrecognized or non-processable content is ignored. @@ -148,7 +148,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op { try { - result = JsonSerializer.Serialize(resultContent.Result, jsonOptions.GetTypeInfo(typeof(object))); + result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -176,7 +176,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op case FunctionCallContent fc: (toolCalls ??= []).Add( ChatToolCall.CreateFunctionToolCall(fc.CallId, fc.Name, new(JsonSerializer.SerializeToUtf8Bytes( - fc.Arguments, jsonOptions.GetTypeInfo(typeof(IDictionary)))))); + fc.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))); break; default: diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index dccddf3038e..9f42fa88773 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -181,6 +181,18 @@ public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// A sequence of OpenAI chat messages. + public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages) => + OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// A sequence of OpenAI response items. + public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages) => + OpenAIResponseChatClient.ToOpenAIResponseItems(Throw.IfNull(messages)); + // TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict. /// Gets whether the properties specify that strict schema handling is desired. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index 6aee4bc77e4..c4a1261844c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -17,6 +17,7 @@ #pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable S3604 // Member initializer values should not be redundant +#pragma warning disable SA1202 // Elements should be ordered by access #pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; @@ -466,8 +467,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt } /// Convert a sequence of s to s. - private static IEnumerable ToOpenAIResponseItems( - IEnumerable inputs) + internal static IEnumerable ToOpenAIResponseItems(IEnumerable inputs) { foreach (ChatMessage input in inputs) { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs deleted file mode 100644 index ce458473c59..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs +++ /dev/null @@ -1,77 +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.ComponentModel; -using System.Text.Json; -using OpenAI.Assistants; -using OpenAI.Chat; -using OpenAI.Realtime; -using OpenAI.Responses; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OpenAIAIFunctionConversionTests -{ - private static readonly AIFunction _testFunction = AIFunctionFactory.Create( - ([Description("The name parameter")] string name) => name, - "test_function", - "A test function for conversion"); - - [Fact] - public void AsOpenAIChatTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIChatTool(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.FunctionName); - Assert.Equal("A test function for conversion", tool.FunctionDescription); - ValidateSchemaParameters(tool.FunctionParameters); - } - - [Fact] - public void AsOpenAIResponseTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIResponseTool(); - - Assert.NotNull(tool); - } - - [Fact] - public void AsOpenAIConversationFunctionTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIConversationFunctionTool(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.Name); - Assert.Equal("A test function for conversion", tool.Description); - ValidateSchemaParameters(tool.Parameters); - } - - [Fact] - public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.FunctionName); - Assert.Equal("A test function for conversion", tool.Description); - ValidateSchemaParameters(tool.Parameters); - } - - /// Helper method to validate function parameters match our schema. - private static void ValidateSchemaParameters(BinaryData parameters) - { - Assert.NotNull(parameters); - - using var jsonDoc = JsonDocument.Parse(parameters); - var root = jsonDoc.RootElement; - - Assert.Equal("object", root.GetProperty("type").GetString()); - Assert.True(root.TryGetProperty("properties", out var properties)); - Assert.True(properties.TryGetProperty("name", out var nameProperty)); - Assert.Equal("string", nameProperty.GetProperty("type").GetString()); - Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs new file mode 100644 index 00000000000..951554eda75 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -0,0 +1,186 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using OpenAI.Assistants; +using OpenAI.Chat; +using OpenAI.Realtime; +using OpenAI.Responses; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIConversionTests +{ + private static readonly AIFunction _testFunction = AIFunctionFactory.Create( + ([Description("The name parameter")] string name) => name, + "test_function", + "A test function for conversion"); + + [Fact] + public void AsOpenAIChatTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIChatTool(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.FunctionDescription); + ValidateSchemaParameters(tool.FunctionParameters); + } + + [Fact] + public void AsOpenAIResponseTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIResponseTool(); + + Assert.NotNull(tool); + } + + [Fact] + public void AsOpenAIConversationFunctionTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIConversationFunctionTool(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.Name); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); + } + + [Fact] + public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); + } + + /// Helper method to validate function parameters match our schema. + private static void ValidateSchemaParameters(BinaryData parameters) + { + Assert.NotNull(parameters); + + using var jsonDoc = JsonDocument.Parse(parameters); + var root = jsonDoc.RootElement; + + Assert.Equal("object", root.GetProperty("type").GetString()); + Assert.True(root.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("name", out var nameProperty)); + Assert.Equal("string", nameProperty.GetProperty("type").GetString()); + Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); + } + + [Fact] + public void AsOpenAIChatMessages_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIChatMessages()); + + List messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, + [ + new TextContent("Hi there!"), + new FunctionCallContent("callid123", "SomeFunction", new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), + ]), + new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), + new(ChatRole.Assistant, "The answer is 42."), + ]; + + var convertedMessages = messages.AsOpenAIChatMessages().ToArray(); + + Assert.Equal(5, convertedMessages.Length); + + SystemChatMessage m0 = Assert.IsType(convertedMessages[0]); + Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); + + UserChatMessage m1 = Assert.IsType(convertedMessages[1]); + Assert.Equal("Hello", Assert.Single(m1.Content).Text); + + AssistantChatMessage m2 = Assert.IsType(convertedMessages[2]); + Assert.Single(m2.Content); + Assert.Equal("Hi there!", m2.Content[0].Text); + var tc = Assert.Single(m2.ToolCalls); + Assert.Equal("callid123", tc.Id); + Assert.Equal("SomeFunction", tc.FunctionName); + Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), JsonSerializer.Deserialize(tc.FunctionArguments.ToMemory().Span))); + + ToolChatMessage m3 = Assert.IsType(convertedMessages[3]); + Assert.Equal("callid123", m3.ToolCallId); + Assert.Equal("theresult", Assert.Single(m3.Content).Text); + + AssistantChatMessage m4 = Assert.IsType(convertedMessages[4]); + Assert.Equal("The answer is 42.", Assert.Single(m4.Content).Text); + } + + [Fact] + public void AsOpenAIResponseItems_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIResponseItems()); + + List messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, + [ + new TextContent("Hi there!"), + new FunctionCallContent("callid123", "SomeFunction", new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), + ]), + new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), + new(ChatRole.Assistant, "The answer is 42."), + ]; + + var convertedItems = messages.AsOpenAIResponseItems().ToArray(); + + Assert.Equal(6, convertedItems.Length); + + MessageResponseItem m0 = Assert.IsAssignableFrom(convertedItems[0]); + Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); + + MessageResponseItem m1 = Assert.IsAssignableFrom(convertedItems[1]); + Assert.Equal(OpenAI.Responses.MessageRole.User, m1.Role); + Assert.Equal("Hello", Assert.Single(m1.Content).Text); + + MessageResponseItem m2 = Assert.IsAssignableFrom(convertedItems[2]); + Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m2.Role); + Assert.Equal("Hi there!", Assert.Single(m2.Content).Text); + + FunctionCallResponseItem m3 = Assert.IsAssignableFrom(convertedItems[3]); + Assert.Equal("callid123", m3.CallId); + Assert.Equal("SomeFunction", m3.FunctionName); + Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), JsonSerializer.Deserialize(m3.FunctionArguments.ToMemory().Span))); + + FunctionCallOutputResponseItem m4 = Assert.IsAssignableFrom(convertedItems[4]); + Assert.Equal("callid123", m4.CallId); + Assert.Equal("theresult", m4.FunctionOutput); + + MessageResponseItem m5 = Assert.IsAssignableFrom(convertedItems[5]); + Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m5.Role); + Assert.Equal("The answer is 42.", Assert.Single(m5.Content).Text); + } +} From fb806a646519e8e0996695ac163795c5d4162573 Mon Sep 17 00:00:00 2001 From: Evgeny Fedorov <25526458+evgenyfedorov2@users.noreply.github.com> Date: Mon, 14 Jul 2025 10:49:39 +0200 Subject: [PATCH 29/71] Add memory usage metric (#6586) --- .../Linux/LinuxUtilizationProvider.cs | 44 ++++-- .../Linux/Log.cs | 8 +- .../Windows/Log.cs | 15 +- .../WindowsContainerSnapshotProvider.cs | 79 ++++++++-- .../Windows/WindowsSnapshotProvider.cs | 2 +- .../ResourceUtilizationInstruments.cs | 8 + .../ResourceHealthCheckExtensionsTests.cs | 1 + .../Linux/AcceptanceTest.cs | 57 +++++-- .../Linux/LinuxUtilizationProviderTests.cs | 17 ++- .../WindowsContainerSnapshotProviderTests.cs | 140 ++++++++++++++++++ 10 files changed, 320 insertions(+), 51 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs index 0c3d4124e17..611b96f4f1d 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs @@ -37,7 +37,7 @@ internal sealed class LinuxUtilizationProvider : ISnapshotProvider private DateTimeOffset _refreshAfterMemory; private double _cpuPercentage = double.NaN; private double _lastCpuCoresUsed = double.NaN; - private double _memoryPercentage; + private ulong _memoryUsage; private long _previousCgroupCpuTime; private long _previousHostCpuTime; private long _previousCgroupCpuPeriodCounter; @@ -116,12 +116,18 @@ public LinuxUtilizationProvider(IOptions options, ILi _ = meter.CreateObservableGauge( name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, - observeValues: () => GetMeasurementWithRetry(MemoryUtilization), + observeValues: () => GetMeasurementWithRetry(MemoryPercentage), unit: "1"); + _ = meter.CreateObservableUpDownCounter( + name: ResourceUtilizationInstruments.ContainerMemoryUsage, + observeValues: () => GetMeasurementWithRetry(() => (long)MemoryUsage()), + unit: "By", + description: "Memory usage of the container."); + _ = meter.CreateObservableGauge( name: ResourceUtilizationInstruments.ProcessMemoryUtilization, - observeValues: () => GetMeasurementWithRetry(MemoryUtilization), + observeValues: () => GetMeasurementWithRetry(MemoryPercentage), unit: "1"); // cpuRequest is a CPU request (aka guaranteed number of CPU units) for pod, for host its 1 core @@ -216,7 +222,7 @@ public double CpuUtilization() return _cpuPercentage; } - public double MemoryUtilization() + public ulong MemoryUsage() { DateTimeOffset now = _timeProvider.GetUtcNow(); @@ -224,26 +230,24 @@ public double MemoryUtilization() { if (now < _refreshAfterMemory) { - return _memoryPercentage; + return _memoryUsage; } } - ulong memoryUsed = _parser.GetMemoryUsageInBytes(); + ulong memoryUsage = _parser.GetMemoryUsageInBytes(); lock (_memoryLocker) { if (now >= _refreshAfterMemory) { - double memoryPercentage = Math.Min(One, (double)memoryUsed / _memoryLimit); - - _memoryPercentage = memoryPercentage; + _memoryUsage = memoryUsage; _refreshAfterMemory = now.Add(_memoryRefreshInterval); } } - _logger.MemoryUsageData(memoryUsed, _memoryLimit, _memoryPercentage); + _logger.MemoryUsageData(_memoryUsage); - return _memoryPercentage; + return _memoryUsage; } /// @@ -264,14 +268,24 @@ public Snapshot GetSnapshot() memoryUsageInBytes: memoryUsed); } - private Measurement[] GetMeasurementWithRetry(Func func) + private double MemoryPercentage() + { + ulong memoryUsage = MemoryUsage(); + double memoryPercentage = Math.Min(One, (double)memoryUsage / _memoryLimit); + + _logger.MemoryPercentageData(memoryUsage, _memoryLimit, memoryPercentage); + return memoryPercentage; + } + + private Measurement[] GetMeasurementWithRetry(Func func) + where T : struct { - if (!TryGetValueWithRetry(func, out double value)) + if (!TryGetValueWithRetry(func, out T value)) { - return Array.Empty>(); + return Array.Empty>(); } - return new[] { new Measurement(value) }; + return new[] { new Measurement(value) }; } private bool TryGetValueWithRetry(Func func, out T value) diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs index 209a495e844..c021f48bb1b 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs @@ -24,7 +24,7 @@ public static partial void CpuUsageData( [LoggerMessage(2, LogLevel.Debug, "Computed memory usage with MemoryUsedInBytes = {memoryUsed}, MemoryLimit = {memoryLimit}, MemoryPercentage = {memoryPercentage}.")] - public static partial void MemoryUsageData( + public static partial void MemoryPercentageData( this ILogger logger, ulong memoryUsed, double memoryLimit, @@ -55,4 +55,10 @@ public static partial void CpuUsageDataV2( public static partial void HandleDiskStatsException( this ILogger logger, string errorMessage); + + [LoggerMessage(6, LogLevel.Debug, + "Computed memory usage = {memoryUsed}.")] + public static partial void MemoryUsageData( + this ILogger logger, + ulong memoryUsed); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs index fdc0d17fe44..e8d8411925d 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs @@ -26,12 +26,11 @@ public static partial void CpuUsageData( double cpuPercentage); [LoggerMessage(4, LogLevel.Debug, - "Computed memory usage with CurrentMemoryUsage = {currentMemoryUsage}, TotalMemory = {totalMemory}, MemoryPercentage = {memoryPercentage}.")] - public static partial void MemoryUsageData( + "Computed memory usage for container: CurrentMemoryUsage = {currentMemoryUsage}, TotalMemory = {totalMemory}")] + public static partial void ContainerMemoryUsageData( this ILogger logger, ulong currentMemoryUsage, - double totalMemory, - double memoryPercentage); + double totalMemory); #pragma warning disable S103 // Lines should not be too long [LoggerMessage(5, LogLevel.Debug, "Computed CPU usage with CpuUsageKernelTicks = {cpuUsageKernelTicks}, CpuUsageUserTicks = {cpuUsageUserTicks}, OldCpuUsageTicks = {oldCpuUsageTicks}, TimeTickDelta = {timeTickDelta}, CpuUnits = {cpuUnits}, CpuPercentage = {cpuPercentage}.")] @@ -60,4 +59,12 @@ public static partial void DiskIoPerfCounterException( this ILogger logger, string counterName, string errorMessage); + + [LoggerMessage(8, LogLevel.Debug, + "Computed memory usage for current process: ProcessMemoryUsage = {processMemoryUsage}, TotalMemory = {totalMemory}, MemoryPercentage = {memoryPercentage}")] + public static partial void ProcessMemoryPercentageData( + this ILogger logger, + ulong processMemoryUsage, + double totalMemory, + double memoryPercentage); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs index ca6ceaff8bd..ce10cad0471 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs @@ -29,6 +29,7 @@ internal sealed class WindowsContainerSnapshotProvider : ISnapshotProvider private readonly object _cpuLocker = new(); private readonly object _memoryLocker = new(); + private readonly object _processMemoryLocker = new(); private readonly TimeProvider _timeProvider; private readonly IProcessInfo _processInfo; private readonly ILogger _logger; @@ -42,8 +43,10 @@ internal sealed class WindowsContainerSnapshotProvider : ISnapshotProvider private long _oldCpuTimeTicks; private DateTimeOffset _refreshAfterCpu; private DateTimeOffset _refreshAfterMemory; + private DateTimeOffset _refreshAfterProcessMemory; private double _cpuPercentage = double.NaN; - private double _memoryPercentage; + private ulong _memoryUsage; + private double _processMemoryPercentage; public SystemResources Resources { get; } @@ -107,6 +110,7 @@ internal WindowsContainerSnapshotProvider( _memoryRefreshInterval = options.MemoryConsumptionRefreshInterval; _refreshAfterCpu = _timeProvider.GetUtcNow(); _refreshAfterMemory = _timeProvider.GetUtcNow(); + _refreshAfterProcessMemory = _timeProvider.GetUtcNow(); #pragma warning disable CA2000 // Dispose objects before losing scope // We don't dispose the meter because IMeterFactory handles that @@ -116,13 +120,34 @@ internal WindowsContainerSnapshotProvider( #pragma warning restore CA2000 // Dispose objects before losing scope // Container based metrics: - _ = meter.CreateObservableCounter(name: ResourceUtilizationInstruments.ContainerCpuTime, observeValues: GetCpuTime, unit: "s", description: "CPU time used by the container."); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: CpuPercentage); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, observeValue: () => MemoryPercentage(() => _processInfo.GetMemoryUsage())); + _ = meter.CreateObservableCounter( + name: ResourceUtilizationInstruments.ContainerCpuTime, + observeValues: GetCpuTime, + unit: "s", + description: "CPU time used by the container."); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, + observeValue: CpuPercentage); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, + observeValue: () => Math.Min(_metricValueMultiplier, MemoryUsage() / _memoryLimit * _metricValueMultiplier)); + + _ = meter.CreateObservableUpDownCounter( + name: ResourceUtilizationInstruments.ContainerMemoryUsage, + observeValue: () => (long)MemoryUsage(), + unit: "By", + description: "Memory usage of the container."); // Process based metrics: - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessCpuUtilization, observeValue: CpuPercentage); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessMemoryUtilization, observeValue: () => MemoryPercentage(() => _processInfo.GetCurrentProcessMemoryUsage())); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessCpuUtilization, + observeValue: CpuPercentage); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessMemoryUtilization, + observeValue: ProcessMemoryPercentage); } public Snapshot GetSnapshot() @@ -185,7 +210,35 @@ private ulong GetMemoryLimit(IJobHandle jobHandle) return memoryLimitInBytes; } - private double MemoryPercentage(Func getMemoryUsage) + private double ProcessMemoryPercentage() + { + DateTimeOffset now = _timeProvider.GetUtcNow(); + + lock (_processMemoryLocker) + { + if (now < _refreshAfterProcessMemory) + { + return _processMemoryPercentage; + } + } + + ulong processMemoryUsage = _processInfo.GetCurrentProcessMemoryUsage(); + + lock (_processMemoryLocker) + { + if (now >= _refreshAfterProcessMemory) + { + _processMemoryPercentage = Math.Min(_metricValueMultiplier, processMemoryUsage / _memoryLimit * _metricValueMultiplier); + _refreshAfterProcessMemory = now.Add(_memoryRefreshInterval); + + _logger.ProcessMemoryPercentageData(processMemoryUsage, _memoryLimit, _processMemoryPercentage); + } + + return _processMemoryPercentage; + } + } + + private ulong MemoryUsage() { DateTimeOffset now = _timeProvider.GetUtcNow(); @@ -193,24 +246,22 @@ private double MemoryPercentage(Func getMemoryUsage) { if (now < _refreshAfterMemory) { - return _memoryPercentage; + return _memoryUsage; } } - ulong memoryUsage = getMemoryUsage(); + ulong memoryUsage = _processInfo.GetMemoryUsage(); lock (_memoryLocker) { if (now >= _refreshAfterMemory) { - // Don't change calculation order, otherwise we loose some precision: - _memoryPercentage = Math.Min(_metricValueMultiplier, memoryUsage / _memoryLimit * _metricValueMultiplier); + _memoryUsage = memoryUsage; _refreshAfterMemory = now.Add(_memoryRefreshInterval); + _logger.ContainerMemoryUsageData(_memoryUsage, _memoryLimit); } - _logger.MemoryUsageData(memoryUsage, _memoryLimit, _memoryPercentage); - - return _memoryPercentage; + return _memoryUsage; } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs index 837cd0f9a06..e238a59aff9 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs @@ -144,7 +144,7 @@ private double MemoryPercentage() _refreshAfterMemory = now.Add(_memoryRefreshInterval); } - _logger.MemoryUsageData((ulong)currentMemoryUsage, _totalMemory, _memoryPercentage); + _logger.ProcessMemoryPercentageData((ulong)currentMemoryUsage, _totalMemory, _memoryPercentage); return _memoryPercentage; } diff --git a/src/Shared/Instruments/ResourceUtilizationInstruments.cs b/src/Shared/Instruments/ResourceUtilizationInstruments.cs index 835d3099782..b1593edd396 100644 --- a/src/Shared/Instruments/ResourceUtilizationInstruments.cs +++ b/src/Shared/Instruments/ResourceUtilizationInstruments.cs @@ -50,6 +50,14 @@ internal static class ResourceUtilizationInstruments /// public const string ContainerMemoryLimitUtilization = "container.memory.limit.utilization"; + /// + /// The name of an instrument to retrieve memory usage measured in bytes of all processes running inside a container or control group. + /// + /// + /// The type of an instrument is . + /// + public const string ContainerMemoryUsage = "container.memory.usage"; + /// /// The name of an instrument to retrieve CPU consumption share of the running process in range [0, 1]. /// diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs index 7d9b347a59d..2599be2dd9e 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs @@ -490,6 +490,7 @@ public async Task TestCpuAndMemoryChecks_WithMetrics( Mock processInfoMock = new(); var appMemoryUsage = memoryUsed; processInfoMock.Setup(p => p.GetMemoryUsage()).Returns(() => appMemoryUsage); + processInfoMock.Setup(p => p.GetCurrentProcessMemoryUsage()).Returns(() => appMemoryUsage); JOBOBJECT_EXTENDED_LIMIT_INFORMATION limitInfo = default; limitInfo.JobMemoryLimit = new UIntPtr(totalMemory); diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs index 0221efc27c9..a4107faef03 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs @@ -220,10 +220,20 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou using var e = new ManualResetEventSlim(); object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, tags, _) - => OnMeasurementReceived(m, f, tags, ref cpuUserTime, ref cpuKernelTime, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => + OnInstrumentPublished(instrument, meterListener, meterScope); + listener.SetMeasurementEventCallback((m, f, tags, _) => + OnMeasurementReceived( + m, + f, + tags, + ref cpuUserTime, + ref cpuKernelTime, + ref cpuFromGauge, + ref cpuLimitFromGauge, + ref cpuRequestFromGauge, + ref memoryFromGauge, + ref memoryLimitFromGauge)); listener.Start(); using var host = FakeHost.CreateBuilder() @@ -302,13 +312,31 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou var cpuRequestFromGauge = 0.0d; var memoryFromGauge = 0.0d; var memoryLimitFromGauge = 0.0d; + long memoryUsageFromGauge = 0; using var e = new ManualResetEventSlim(); object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, tags, _) - => OnMeasurementReceived(m, f, tags, ref cpuUserTime, ref cpuKernelTime, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => + OnInstrumentPublished(instrument, meterListener, meterScope); + listener.SetMeasurementEventCallback((m, f, tags, _) => + OnMeasurementReceived( + m, + f, + tags, + ref cpuUserTime, + ref cpuKernelTime, + ref cpuFromGauge, + ref cpuLimitFromGauge, + ref cpuRequestFromGauge, + ref memoryFromGauge, + ref memoryLimitFromGauge)); + listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + if (instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage) + { + memoryUsageFromGauge = value; + } + }); listener.Start(); using var host = FakeHost.CreateBuilder() @@ -355,6 +383,7 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou Assert.Equal(1, roundedCpuUsedPercentage); Assert.Equal(50, utilization.MemoryUsedPercentage); + Assert.Equal(524288, memoryUsageFromGauge); Assert.Equal(0.5, cpuLimitFromGauge * 100); Assert.Equal(roundedCpuUsedPercentage, Math.Round(cpuRequestFromGauge * 100)); Assert.Equal(utilization.MemoryUsedPercentage, memoryLimitFromGauge * 100); @@ -392,10 +421,11 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou var memoryLimitFromGauge = 0.0d; object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, tags, _) - => OnMeasurementReceived(m, + listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => + OnInstrumentPublished(instrument, meterListener, meterScope); + listener.SetMeasurementEventCallback((m, f, tags, _) => + OnMeasurementReceived( + m, f, tags, ref cpuUserTime, @@ -455,7 +485,8 @@ private static void OnInstrumentPublished(Instrument instrument, MeterListener m instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime || instrument.Name == ResourceUtilizationInstruments.ContainerCpuRequestUtilization || instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization || - instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization) + instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization || + instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage) { meterListener.EnableMeasurementEvents(instrument); } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs index b34dfb1c258..c60ec5fa834 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs @@ -72,10 +72,18 @@ public void Provider_Registers_Instruments() } }); + listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + samples.Add((instrument, value)); + } + }); + listener.Start(); listener.RecordObservableInstruments(); - Assert.Equal(5, samples.Count); + Assert.Equal(6, samples.Count); Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization).value)); @@ -86,6 +94,9 @@ public void Provider_Registers_Instruments() Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization); Assert.Equal(0.5, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization).value); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage); + Assert.Equal(524288, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage).value); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization).value)); @@ -359,7 +370,7 @@ public void Provider_GetMeasurementWithRetry_UnhandledException_DoesNotBlockFutu parserMock.Setup(p => p.GetMemoryUsageInBytes()).Returns(() => { callCount++; - if (callCount <= 2) + if (callCount <= 3) { throw new InvalidOperationException("Simulated unhandled exception"); } @@ -403,6 +414,6 @@ public void Provider_GetMeasurementWithRetry_UnhandledException_DoesNotBlockFutu var metric = samples.SingleOrDefault(x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); Assert.Equal(1234f / 2000f, metric.value, 0.01f); - parserMock.Verify(p => p.GetMemoryUsageInBytes(), Times.Exactly(3)); + parserMock.Verify(p => p.GetMemoryUsageInBytes(), Times.Exactly(4)); } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs index fc9113eacc9..ead512e015b 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs @@ -379,6 +379,146 @@ public void SnapshotProvider_EmitsMemoryMetrics(string instrumentName, bool useZ Assert.Equal(0.3 * multiplier, metricCollector.LastMeasurement.Value); // Consuming 30% of the memory afterwards. } + [Fact] + public void SnapshotProvider_TestMemoryMetricsTogether() + { + _appMemoryUsage = 200UL; + ulong containerMemoryUsage = 400UL; + ulong updatedAppMemoryUsage = 600UL; + ulong updatedContainerMemoryUsage = 1200UL; + + _processInfoMock.SetupSequence(p => p.GetCurrentProcessMemoryUsage()) + .Returns(() => _appMemoryUsage) + .Returns(updatedAppMemoryUsage) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + _processInfoMock.SetupSequence(p => p.GetMemoryUsage()) + .Returns(() => containerMemoryUsage) + .Returns(updatedContainerMemoryUsage) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + var fakeClock = new FakeTimeProvider(); + using var meter = new Meter(nameof(SnapshotProvider_TestMemoryMetricsTogether)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + using var processMetricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ProcessMemoryUtilization, fakeClock); + using var containerLimitMetricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, fakeClock); + using var containerUsageMetricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerMemoryUsage, fakeClock); + + var options = new ResourceMonitoringOptions + { + MemoryConsumptionRefreshInterval = TimeSpan.FromMilliseconds(2) + }; + var snapshotProvider = new WindowsContainerSnapshotProvider( + _memoryInfoMock.Object, + _systemInfoMock.Object, + _processInfoMock.Object, + _logger, + meterFactoryMock.Object, + () => _jobHandleMock.Object, + fakeClock, + options); + + // Step #0 - state in the beginning: + processMetricCollector.RecordObservableInstruments(); + containerLimitMetricCollector.RecordObservableInstruments(); + containerUsageMetricCollector.RecordObservableInstruments(); + + Assert.NotNull(processMetricCollector.LastMeasurement?.Value); + Assert.NotNull(containerLimitMetricCollector.LastMeasurement?.Value); + Assert.NotNull(containerUsageMetricCollector.LastMeasurement?.Value); + + Assert.Equal(10, processMetricCollector.LastMeasurement.Value); // Process is consuming 10% of memory limit initially. + Assert.Equal(20, containerLimitMetricCollector.LastMeasurement.Value); // The whole container is consuming 20% of the memory limit initially. + Assert.Equal((long)containerMemoryUsage, containerUsageMetricCollector.LastMeasurement.Value); // 400 bytes of memory usage initially. + + // Step #1 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(options.MemoryConsumptionRefreshInterval - TimeSpan.FromMilliseconds(1)); + + processMetricCollector.RecordObservableInstruments(); + containerUsageMetricCollector.RecordObservableInstruments(); + containerLimitMetricCollector.RecordObservableInstruments(); + + // Still consuming 10% and 20% as values weren't updated yet - not enough time passed. + Assert.Equal(10, processMetricCollector.LastMeasurement.Value); + Assert.Equal(20, containerLimitMetricCollector.LastMeasurement.Value); + Assert.Equal((long)containerMemoryUsage, containerUsageMetricCollector.LastMeasurement.Value); + + // Step #2 - simulate 2 milliseconds passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(1)); + + processMetricCollector.RecordObservableInstruments(); + containerLimitMetricCollector.RecordObservableInstruments(); + containerUsageMetricCollector.RecordObservableInstruments(); + + // App is consuming 30%, and container is consuming 60% of the limit: + Assert.Equal(30, processMetricCollector.LastMeasurement.Value); + Assert.Equal(60, containerLimitMetricCollector.LastMeasurement.Value); + Assert.Equal((long)updatedContainerMemoryUsage, containerUsageMetricCollector.LastMeasurement.Value); + } + + [Fact] + public void SnapshotProvider_EmitsMemoryUsageMetric() + { + _appMemoryUsage = 200UL; + const ulong UpdatedAppMemoryUsage = 600UL; + const ulong UpdatedAppMemoryUsage2 = 300UL; + + _processInfoMock.SetupSequence(p => p.GetCurrentProcessMemoryUsage()) + .Returns(() => _appMemoryUsage) + .Returns(UpdatedAppMemoryUsage) + .Returns(UpdatedAppMemoryUsage2) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + _processInfoMock.SetupSequence(p => p.GetMemoryUsage()) + .Returns(() => _appMemoryUsage) + .Returns(UpdatedAppMemoryUsage) + .Returns(UpdatedAppMemoryUsage2) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + var fakeClock = new FakeTimeProvider(); + using var meter = new Meter(nameof(SnapshotProvider_EmitsMemoryMetrics)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + using var metricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerMemoryUsage, fakeClock); + + var options = new ResourceMonitoringOptions + { + MemoryConsumptionRefreshInterval = TimeSpan.FromMilliseconds(2), + }; + var snapshotProvider = new WindowsContainerSnapshotProvider( + _memoryInfoMock.Object, + _systemInfoMock.Object, + _processInfoMock.Object, + _logger, + meterFactoryMock.Object, + () => _jobHandleMock.Object, + fakeClock, + options); + + // Step #0 - state in the beginning: + metricCollector.RecordObservableInstruments(); + Assert.NotNull(metricCollector.LastMeasurement?.Value); + Assert.Equal(200, metricCollector.LastMeasurement.Value); // Consuming 200 bytes initially. + + // Step #1 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(options.MemoryConsumptionRefreshInterval - TimeSpan.FromMilliseconds(1)); + metricCollector.RecordObservableInstruments(); + Assert.Equal(200, metricCollector.LastMeasurement.Value); // Still consuming 200 bytes as metric wasn't updated. + + // Step #2 - simulate 2 milliseconds passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(2)); + metricCollector.RecordObservableInstruments(); + Assert.Equal(600, metricCollector.LastMeasurement.Value); // Consuming 600 bytes. + + // Step #3 - simulate 2 milliseconds passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(2)); + metricCollector.RecordObservableInstruments(); + Assert.Equal(300, metricCollector.LastMeasurement.Value); // Consuming 300 bytes. + } + [Fact] public Task SnapshotProvider_EmitsLogRecord() { From 633d8016f63d00caf3b86e9986efa754dbe677a1 Mon Sep 17 00:00:00 2001 From: Joel Verhagen Date: Mon, 14 Jul 2025 13:41:42 -0400 Subject: [PATCH 30/71] Add schema version to server.json in MCP template (#6606) * Add schema version to server.json * Fix test --- .../src/McpServer/McpServer-CSharp/.mcp/server.json | 1 + .../mcpserver.Basic.verified/mcpserver/.mcp/server.json | 1 + 2 files changed, 2 insertions(+) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json index d4b9d0edf5b..34c19714f79 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json @@ -1,4 +1,5 @@ { + "$schema": "https://modelcontextprotocol.io/schemas/draft/2025-07-09/server.json", "description": "", "name": "io.github./", "packages": [ diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json index ab997541e52..02908c09afb 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json @@ -1,4 +1,5 @@ { + "$schema": "https://modelcontextprotocol.io/schemas/draft/2025-07-09/server.json", "description": "", "name": "io.github./", "packages": [ From dfd77b919a03d122e71eb13f9547b3a7cca526af Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Jul 2025 23:16:42 -0700 Subject: [PATCH 31/71] Update MCP server template readme to show both VS Code and Visual Studio notes (#6591) * Initial plan * Add VS IDE-specific README and update template configuration Co-authored-by: timheuer <4821+timheuer@users.noreply.github.com> * Update to single README with both VS Code and Visual Studio sections Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> * Fix Visual Studio MCP documentation URL Co-authored-by: timheuer <4821+timheuer@users.noreply.github.com> * Fix Visual Studio MCP JSON configuration format Co-authored-by: timheuer <4821+timheuer@users.noreply.github.com> * Restructure README to reduce repetition between VS Code and Visual Studio sections Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> * Eliminate repetitive JSON configuration in README by consolidating server definitions Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> * Revise the mcp server template README * Update MCP template README paths and sync snapshot with source template Co-authored-by: joelverhagen <94054+joelverhagen@users.noreply.github.com> * Update mcpserver project template baseline * Bump MEAI.Templates package to preview.3. * Add feedback survey to mcpserver project template README --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: timheuer <4821+timheuer@users.noreply.github.com> Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> Co-authored-by: Jeff Handley Co-authored-by: joelverhagen <94054+joelverhagen@users.noreply.github.com> --- .../Microsoft.Extensions.AI.Templates.csproj | 2 +- .../src/McpServer/McpServer-CSharp/README.md | 85 ++++++++++--------- .../mcpserver/README.md | 85 ++++++++++--------- 3 files changed, 91 insertions(+), 81 deletions(-) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 7784747028e..ab5ef554a3a 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -7,7 +7,7 @@ dotnet-new;templates;ai preview - 2 + 3 AI 0 0 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md index 50091888ad8..cb11ac30eb5 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md @@ -1,9 +1,11 @@ # MCP Server -This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. +This README was created using the C# MCP server project template. It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + ## Checklist before publishing to NuGet.org - Test the MCP server locally using the steps below. @@ -14,67 +16,70 @@ See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). -## Using the MCP Server in VS Code +## Developing locally -Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. ```json { - "mcp": { - "servers": { - "McpServer-CSharp": { - "type": "stdio", - "command": "dnx", - "args": [ - "", - "--version", - "", - "--yes" - ] - } + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] } } } ``` -Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `McpServer-CSharp` MCP server and show you the results. +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `McpServer-CSharp` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org -## Developing locally in VS Code +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. -To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: ```json { "servers": { "McpServer-CSharp": { "type": "stdio", - "command": "dotnet", + "command": "dnx", "args": [ - "run", - "--project", - "" + "", + "--version", + "", + "--yes" ] } } } ``` -Alternatively, you can configure your VS Code user settings to use your local project: +## More information -```json -{ - "mcp": { - "servers": { - "McpServer-CSharp": { - "type": "stdio", - "command": "dotnet", - "args": [ - "run", - "--project", - "" - ] - } - } - } -} -``` +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md index 5c00a3bf669..a0bf0fc082d 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md @@ -1,9 +1,11 @@ # MCP Server -This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. +This README was created using the C# MCP server project template. It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + ## Checklist before publishing to NuGet.org - Test the MCP server locally using the steps below. @@ -14,67 +16,70 @@ See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). -## Using the MCP Server in VS Code +## Developing locally -Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. ```json { - "mcp": { - "servers": { - "mcpserver": { - "type": "stdio", - "command": "dnx", - "args": [ - "", - "--version", - "", - "--yes" - ] - } + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] } } } ``` -Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org -## Developing locally in VS Code +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. -To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: ```json { "servers": { "mcpserver": { "type": "stdio", - "command": "dotnet", + "command": "dnx", "args": [ - "run", - "--project", - "" + "", + "--version", + "", + "--yes" ] } } } ``` -Alternatively, you can configure your VS Code user settings to use your local project: +## More information -```json -{ - "mcp": { - "servers": { - "mcpserver": { - "type": "stdio", - "command": "dotnet", - "args": [ - "run", - "--project", - "" - ] - } - } - } -} -``` +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) From 7874d8ffcda171371df549421c96af1371c0b746 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 15 Jul 2025 11:50:21 +0300 Subject: [PATCH 32/71] Fix schema generation for Nullable function parameters. (#6596) * Fix schema generation for Nullable function parameters. * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs * Incorporate fix from https://github.com/dotnet/runtime/issues/117493. * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs * Extend fix to include AllowReadingFromString. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AIJsonUtilities.Schema.Create.cs | 60 +++++++++++++--- .../JsonSchemaExporter.ReflectionHelpers.cs | 2 - .../JsonSchemaExporter/JsonSchemaExporter.cs | 16 +++-- .../AssertExtensions.cs | 24 ++++--- .../Utilities/AIJsonUtilitiesTests.cs | 16 +++-- .../Functions/AIFunctionFactoryTest.cs | 68 +++++++++++++++++++ test/Shared/JsonSchemaExporter/TestData.cs | 10 ++- test/Shared/JsonSchemaExporter/TestTypes.cs | 47 ++++++------- 8 files changed, 184 insertions(+), 59 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index a9d3ac3e3ee..c77e7dffb5b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -289,24 +289,49 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js objSchema.InsertAtStart(TypePropertyName, "string"); } - // Include the type keyword in nullable enum types - if (Nullable.GetUnderlyingType(ctx.TypeInfo.Type)?.IsEnum is true && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) - { - objSchema.InsertAtStart(TypePropertyName, new JsonArray { (JsonNode)"string", (JsonNode)"null" }); - } - // Some consumers of the JSON schema, including Ollama as of v0.3.13, don't understand // schemas with "type": [...], and only understand "type" being a single value. // In certain configurations STJ represents .NET numeric types as ["string", "number"], which will then lead to an error. - if (TypeIsIntegerWithStringNumberHandling(ctx, objSchema, out string? numericType)) + if (TypeIsIntegerWithStringNumberHandling(ctx, objSchema, out string? numericType, out bool isNullable)) { // We don't want to emit any array for "type". In this case we know it contains "integer" or "number", // so reduce the type to that alone, assuming it's the most specific type. // This makes schemas for Int32 (etc) work with Ollama. JsonObject obj = ConvertSchemaToObject(ref schema); - obj[TypePropertyName] = numericType; + if (isNullable) + { + // If the type is nullable, we still need use a type array + obj[TypePropertyName] = new JsonArray { (JsonNode)numericType, (JsonNode)"null" }; + } + else + { + obj[TypePropertyName] = (JsonNode)numericType; + } + _ = obj.Remove(PatternPropertyName); } + + if (Nullable.GetUnderlyingType(ctx.TypeInfo.Type) is Type nullableElement) + { + // Account for bug https://github.com/dotnet/runtime/issues/117493 + // To be removed once System.Text.Json v10 becomes the lowest supported version. + // null not inserted in the type keyword for root-level Nullable types. + if (objSchema.TryGetPropertyValue(TypePropertyName, out JsonNode? typeKeyWord) && + typeKeyWord?.GetValueKind() is JsonValueKind.String) + { + string typeValue = typeKeyWord.GetValue()!; + if (typeValue is not "null") + { + objSchema[TypePropertyName] = new JsonArray { (JsonNode)typeValue, (JsonNode)"null" }; + } + } + + // Include the type keyword in nullable enum types + if (nullableElement.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) + { + objSchema.InsertAtStart(TypePropertyName, new JsonArray { (JsonNode)"string", (JsonNode)"null" }); + } + } } if (ctx.Path.IsEmpty && hasDefaultValue) @@ -601,11 +626,12 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali } } - private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateContext ctx, JsonObject schema, [NotNullWhen(true)] out string? numericType) + private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateContext ctx, JsonObject schema, [NotNullWhen(true)] out string? numericType, out bool isNullable) { numericType = null; + isNullable = false; - if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray { Count: 2 } typeArray) + if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray typeArray) { bool allowString = false; @@ -617,11 +643,23 @@ private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateCont switch (type) { case "integer" or "number": + if (numericType is not null) + { + // Conflicting numeric type + return false; + } + numericType = type; break; case "string": allowString = true; break; + case "null": + isNullable = true; + break; + default: + // keyword is not valid in the context of numeric types. + return false; } } } @@ -665,7 +703,7 @@ private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) if (defaultValue is null || (defaultValue == DBNull.Value && parameterType != typeof(DBNull))) { - return parameterType.IsValueType + return parameterType.IsValueType && Nullable.GetUnderlyingType(parameterType) is null #if NET ? RuntimeHelpers.GetUninitializedObject(parameterType) #else diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs index 481e5f75753..6d350dab026 100644 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs @@ -31,8 +31,6 @@ private static class ReflectionHelpers public static bool IsBuiltInConverter(JsonConverter converter) => converter.GetType().Assembly == typeof(JsonConverter).Assembly; - public static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; - public static Type GetElementType(JsonTypeInfo typeInfo) { Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary, "TypeInfo must be of collection type"); diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs index 2d8ffc5497c..d651ce6a727 100644 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs @@ -452,20 +452,24 @@ JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) bool IsNullableSchema(ref GenerationState state) { - // A schema is marked as nullable if either + // A schema is marked as nullable if either: // 1. We have a schema for a property where either the getter or setter are marked as nullable. - // 2. We have a schema for a reference type, unless we're explicitly treating null-oblivious types as non-nullable + // 2. We have a schema for a Nullable type. + // 3. We have a schema for a reference type, unless we're explicitly treating null-oblivious types as non-nullable. if (propertyInfo != null || parameterInfo != null) { return !isNonNullableType; } - else + + if (Nullable.GetUnderlyingType(typeInfo.Type) is not null) { - return ReflectionHelpers.CanBeNull(typeInfo.Type) && - !parentPolymorphicTypeIsNonNullable && - !state.ExporterOptions.TreatNullObliviousAsNonNullable; + return true; } + + return !typeInfo.Type.IsValueType && + !parentPolymorphicTypeIsNonNullable && + !state.ExporterOptions.TreatNullObliviousAsNonNullable; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs index 72985108c6e..6361fe7817e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs @@ -53,21 +53,29 @@ public static void EqualFunctionCallParameters( public static void EqualFunctionCallResults(object? expected, object? actual, JsonSerializerOptions? options = null) => AreJsonEquivalentValues(expected, actual, options); - private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) + /// + /// Asserts that the two JSON values are equal. + /// + public static void EqualJsonValues(JsonElement expectedJson, JsonElement actualJson, string? propertyName = null) { - options ??= AIJsonUtilities.DefaultOptions; - JsonElement expectedElement = NormalizeToElement(expected, options); - JsonElement actualElement = NormalizeToElement(actual, options); if (!JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(expectedElement, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(actualElement, AIJsonUtilities.DefaultOptions))) + JsonSerializer.SerializeToNode(expectedJson, AIJsonUtilities.DefaultOptions), + JsonSerializer.SerializeToNode(actualJson, AIJsonUtilities.DefaultOptions))) { string message = propertyName is null - ? $"Function result does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}" - : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}"; + ? $"JSON result does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}" + : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}"; throw new XunitException(message); } + } + + private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) + { + options ??= AIJsonUtilities.DefaultOptions; + JsonElement expectedElement = NormalizeToElement(expected, options); + JsonElement actualElement = NormalizeToElement(actual, options); + EqualJsonValues(expectedElement, actualElement, propertyName); static JsonElement NormalizeToElement(object? value, JsonSerializerOptions options) => value is JsonElement e ? e : JsonSerializer.SerializeToElement(value, options); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index 19b2fc8bb48..c2177486fea 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -354,13 +354,21 @@ public static void CreateFunctionJsonSchema_TreatsIntegralTypesAsInteger_EvenWit int i = 0; foreach (JsonProperty property in schemaParameters.EnumerateObject()) { - string numericType = Type.GetTypeCode(parameters[i].ParameterType) is TypeCode.Double or TypeCode.Single or TypeCode.Decimal - ? "number" - : "integer"; + bool isNullable = false; + Type type = parameters[i].ParameterType; + if (Nullable.GetUnderlyingType(type) is { } elementType) + { + type = elementType; + isNullable = true; + } + + string numericType = Type.GetTypeCode(type) is TypeCode.Double or TypeCode.Single or TypeCode.Decimal + ? "\"number\"" + : "\"integer\""; JsonElement expected = JsonDocument.Parse($$""" { - "type": "{{numericType}}" + "type": {{(isNullable ? $"[{numericType}, \"null\"]" : numericType)}} } """).RootElement; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 8c0c7d057a6..69787dc868b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; @@ -854,6 +855,71 @@ public async Task AIFunctionFactory_DefaultDefaultParameter() Assert.Contains("00000000-0000-0000-0000-000000000000,0", result?.ToString()); } + [Fact] + public async Task AIFunctionFactory_NullableParameters() + { + Assert.NotEqual(new StructWithDefaultCtor().Value, default(StructWithDefaultCtor).Value); + + AIFunction f = AIFunctionFactory.Create( + (int? limit = null, DateTime? from = null) => Enumerable.Repeat(from ?? default, limit ?? 4).Select(d => d.Year).ToArray(), + serializerOptions: JsonContext.Default.Options); + + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "limit": { + "type": ["integer", "null"], + "default": null + }, + "from": { + "type": ["string", "null"], + "format": "date-time", + "default": null + } + } + } + """).RootElement; + + AssertExtensions.EqualJsonValues(expectedSchema, f.JsonSchema); + + object? result = await f.InvokeAsync(); + Assert.Contains("[1,1,1,1]", result?.ToString()); + } + + [Fact] + public async Task AIFunctionFactory_NullableParameters_AllowReadingFromString() + { + JsonSerializerOptions options = new(JsonContext.Default.Options) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + Assert.NotEqual(new StructWithDefaultCtor().Value, default(StructWithDefaultCtor).Value); + + AIFunction f = AIFunctionFactory.Create( + (int? limit = null, DateTime? from = null) => Enumerable.Repeat(from ?? default, limit ?? 4).Select(d => d.Year).ToArray(), + serializerOptions: options); + + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "limit": { + "type": ["integer", "null"], + "default": null + }, + "from": { + "type": ["string", "null"], + "format": "date-time", + "default": null + } + } + } + """).RootElement; + + AssertExtensions.EqualJsonValues(expectedSchema, f.JsonSchema); + + object? result = await f.InvokeAsync(); + Assert.Contains("[1,1,1,1]", result?.ToString()); + } + [Fact] public void AIFunctionFactory_ReturnTypeWithDescriptionAttribute() { @@ -959,5 +1025,7 @@ private static AIFunctionFactoryOptions CreateKeyedServicesSupportOptions() => [JsonSerializable(typeof(Guid))] [JsonSerializable(typeof(StructWithDefaultCtor))] [JsonSerializable(typeof(B))] + [JsonSerializable(typeof(int?))] + [JsonSerializable(typeof(DateTime?))] private partial class JsonContext : JsonSerializerContext; } diff --git a/test/Shared/JsonSchemaExporter/TestData.cs b/test/Shared/JsonSchemaExporter/TestData.cs index 26902bfe0db..7c7cc7fc9a7 100644 --- a/test/Shared/JsonSchemaExporter/TestData.cs +++ b/test/Shared/JsonSchemaExporter/TestData.cs @@ -13,7 +13,9 @@ internal sealed record TestData( T? Value, [StringSyntax(StringSyntaxAttribute.Json)] string ExpectedJsonSchema, IEnumerable? AdditionalValues = null, - object? ExporterOptions = null, +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL + System.Text.Json.Schema.JsonSchemaExporterOptions? ExporterOptions = null, +#endif JsonSerializerOptions? Options = null, bool WritesNumbersAsStrings = false) : ITestData @@ -22,7 +24,9 @@ internal sealed record TestData( public Type Type => typeof(T); object? ITestData.Value => Value; +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL object? ITestData.ExporterOptions => ExporterOptions; +#endif JsonNode ITestData.ExpectedJsonSchema { get; } = JsonNode.Parse(ExpectedJsonSchema, documentOptions: _schemaParseOptions) ?? throw new ArgumentNullException("schema must not be null"); @@ -32,7 +36,7 @@ IEnumerable ITestData.GetTestDataForAllValues() yield return this; if (default(T) is null && -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL ExporterOptions is System.Text.Json.Schema.JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable: false } && #endif Value is not null) @@ -58,7 +62,9 @@ public interface ITestData JsonNode ExpectedJsonSchema { get; } +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL object? ExporterOptions { get; } +#endif JsonSerializerOptions? Options { get; } diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index 7cfd0ce45be..794e58fa2b8 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -9,12 +9,9 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Linq; -#if NET9_0_OR_GREATER -using System.Reflection; -#endif using System.Text.Json; using System.Text.Json.Nodes; -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL using System.Text.Json.Schema; #endif using System.Text.Json.Serialization; @@ -135,6 +132,21 @@ public static IEnumerable GetTestDataCore() } """); +#if !NET9_0 && TESTS_JSON_SCHEMA_EXPORTER_POLYFILL + // Regression test for https://github.com/dotnet/runtime/issues/117493 + yield return new TestData( + Value: 42, + AdditionalValues: [null], + ExpectedJsonSchema: """{"type":["integer","null"]}""", + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); + + yield return new TestData( + Value: DateTimeOffset.MinValue, + AdditionalValues: [null], + ExpectedJsonSchema: """{"type":["string","null"],"format":"date-time"}""", + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); +#endif + // User-defined POCOs yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -152,7 +164,7 @@ public static IEnumerable GetTestDataCore() } """); -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL // Same as above but with nullable types set to non-nullable yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -311,7 +323,7 @@ public static IEnumerable GetTestDataCore() } """); -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL // Same as above but with non-nullable reference types by default. yield return new TestData( Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, @@ -761,7 +773,7 @@ of the type which points to the first occurrence. */ } """); -#if NET9_0_OR_GREATER +#if TEST yield return new TestData( Value: new("string", -1), ExpectedJsonSchema: """ @@ -1164,7 +1176,7 @@ public readonly struct StructDictionary(IEnumerable _dictionary.Count; public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key); public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); -#if NETCOREAPP +#if NET public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) => _dictionary.TryGetValue(key, out value); #else public bool TryGetValue(TKey key, out TValue value) => _dictionary.TryGetValue(key, out value); @@ -1249,6 +1261,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions [JsonSerializable(typeof(IntEnum?))] [JsonSerializable(typeof(StringEnum?))] [JsonSerializable(typeof(SimpleRecordStruct?))] + [JsonSerializable(typeof(DateTimeOffset?))] // User-defined POCOs [JsonSerializable(typeof(SimplePoco))] [JsonSerializable(typeof(SimpleRecord))] @@ -1299,22 +1312,4 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions [JsonSerializable(typeof(StructDictionary))] [JsonSerializable(typeof(XElement))] public partial class TestTypesContext : JsonSerializerContext; - -#if NET9_0_OR_GREATER - private static TAttribute? ResolveAttribute(this JsonSchemaExporterContext ctx) - where TAttribute : Attribute - { - // Resolve attributes from locations in the following order: - // 1. Property-level attributes - // 2. Parameter-level attributes and - // 3. Type-level attributes. - return - GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? - GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? - GetAttrs(ctx.TypeInfo.Type); - - static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => - (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); - } -#endif } From acf1d4520870c4d0ce2ea51b449212829484d8a8 Mon Sep 17 00:00:00 2001 From: Shyam N Date: Tue, 15 Jul 2025 03:34:32 -0700 Subject: [PATCH 33/71] Update Azure Open AI package (#6609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change is required to address the following error when running eval integration tests - looks like the version of OpenAI package referenced in the product code was incompatible with the preview version of Azure Open AI package referenced in the test code. ``` System.TypeLoadException HResult=0x80131522 Message=Could not load type 'OpenAI.RealtimeConversation.RealtimeConversationClient' from assembly 'OpenAI, Version=2.2.0.0, Culture=neutral, PublicKeyToken=b4187f3e65366280'. Source=Azure.AI.OpenAI StackTrace:   at Azure.AI.OpenAI.AzureOpenAIClientOptions.GetRawServiceApiValueForClient(Object client)   at Azure.AI.OpenAI.Chat.AzureChatClient..ctor(ClientPipeline pipeline, String deploymentName, Uri endpoint, AzureOpenAIClientOptions options)   at Azure.AI.OpenAI.AzureOpenAIClient.GetChatClient(String deploymentName)   at Microsoft.Extensions.AI.Evaluation.Integration.Tests.Setup.CreateChatConfiguration() in Q:\src\extensions\test\Libraries\Microsoft.Extensions.AI.Evaluation.Integration.Tests\Setup.cs:line 19 ``` --- eng/packages/TestOnly.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 3b511fa037f..9bf2edcca4a 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -2,7 +2,7 @@ - + From ed4aeac58f4646a2816987991d6abb9719f59de0 Mon Sep 17 00:00:00 2001 From: Joel Verhagen Date: Wed, 16 Jul 2025 13:34:02 -0400 Subject: [PATCH 34/71] Target .NET 8 for more stable runtime requirement (#6617) * Target .NET 8 for more stable runtime requirement * Address comment --- src/ProjectTemplates/GeneratedContent.targets | 2 +- .../src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in | 3 ++- .../mcpserver.Basic.verified/mcpserver/mcpserver.csproj | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index dfe93dbc5a8..c738ee0b547 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -35,7 +35,7 @@ 1.14.0 11.6.0 9.4.1-beta.291 - 10.0.0-preview.5.25277.114 + 10.0.0-preview.6.25358.103 9.3.0 1.53.0 1.53.0-preview diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in index d47952229d9..2eca37df228 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in @@ -1,7 +1,8 @@ - net10.0 + net8.0 + Major Exe enable enable diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj index e959c64702f..468230d16e4 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj @@ -1,7 +1,8 @@  - net10.0 + net8.0 + Major Exe enable enable From 73b4861518efd50a6da10d8141b86c9467736ca5 Mon Sep 17 00:00:00 2001 From: Shyam N Date: Wed, 16 Jul 2025 17:19:29 -0700 Subject: [PATCH 35/71] Add support for new Azure AI Foundry project type for Safety evals (#6621) Fixes #6592 --- .../ContentSafetyChatClient.cs | 27 ++- .../ContentSafetyService.UrlCacheKey.cs | 4 + .../ContentSafetyService.cs | 55 +++-- .../ContentSafetyServiceConfiguration.cs | 220 ++++++++++++++---- .../SafetyEvaluatorTests.cs | 89 ++++++- .../Settings.cs | 5 + .../appsettings.json | 3 +- 7 files changed, 325 insertions(+), 78 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs index 347975cb695..a8c1ba889d2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs @@ -35,16 +35,27 @@ public ContentSafetyChatClient( ChatClientMetadata? originalMetadata = _originalChatClient?.GetService(); - string providerName = - $"{Moniker} (" + - $"Subscription: {contentSafetyServiceConfiguration.SubscriptionId}, " + - $"Resource Group: {contentSafetyServiceConfiguration.ResourceGroupName}, " + - $"Project: {contentSafetyServiceConfiguration.ProjectName})"; + string providerName; + Uri? providerUri = originalMetadata?.ProviderUri; + + if (contentSafetyServiceConfiguration.IsHubBasedProject) + { + providerName = + $"{Moniker} (" + + $"Subscription: {contentSafetyServiceConfiguration.SubscriptionId}, " + + $"Resource Group: {contentSafetyServiceConfiguration.ResourceGroupName}, " + + $"Project: {contentSafetyServiceConfiguration.ProjectName})"; + } + else + { + providerName = $"{Moniker} (Endpoint: {contentSafetyServiceConfiguration.Endpoint})"; + providerUri = contentSafetyServiceConfiguration.Endpoint; + } if (originalMetadata?.ProviderName is string originalProviderName && !string.IsNullOrWhiteSpace(originalProviderName)) { - providerName = $"{originalProviderName}; {providerName}"; + providerName = $"{providerName}; {originalProviderName}"; } string modelId = Moniker; @@ -52,10 +63,10 @@ public ContentSafetyChatClient( if (originalMetadata?.DefaultModelId is string originalModelId && !string.IsNullOrWhiteSpace(originalModelId)) { - modelId = $"{originalModelId}; {modelId}"; + modelId = $"{modelId}; {originalModelId}"; } - _metadata = new ChatClientMetadata(providerName, originalMetadata?.ProviderUri, modelId); + _metadata = new ChatClientMetadata(providerName, providerUri, modelId); } public async Task GetResponseAsync( diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs index 41be29e9ed3..c8969a001c4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs @@ -26,11 +26,14 @@ public bool Equals(UrlCacheKey? other) } else { +#pragma warning disable S1067 // Expressions should not be too complex return other.Configuration.SubscriptionId == Configuration.SubscriptionId && other.Configuration.ResourceGroupName == Configuration.ResourceGroupName && other.Configuration.ProjectName == Configuration.ProjectName && + other.Configuration.Endpoint == Configuration.Endpoint && other.AnnotationTask == AnnotationTask; +#pragma warning restore S1067 } } @@ -42,6 +45,7 @@ public override int GetHashCode() => Configuration.SubscriptionId, Configuration.ResourceGroupName, Configuration.ProjectName, + Configuration.Endpoint, AnnotationTask); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs index 6028a82544c..ee9bdf2c926 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs @@ -22,6 +22,9 @@ namespace Microsoft.Extensions.AI.Evaluation.Safety; internal sealed partial class ContentSafetyService(ContentSafetyServiceConfiguration serviceConfiguration) { + private const string APIVersionForServiceDiscoveryInHubBasedProjects = "?api-version=2023-08-01-preview"; + private const string APIVersionForNonHubBasedProjects = "?api-version=2025-05-15-preview"; + private static HttpClient? _sharedHttpClient; private static HttpClient SharedHttpClient { @@ -168,20 +171,27 @@ private async ValueTask GetServiceUrlAsync( return _serviceUrl; } - string discoveryUrl = - await GetServiceDiscoveryUrlAsync(evaluatorName, cancellationToken).ConfigureAwait(false); - - serviceUrl = - $"{discoveryUrl}/raisvc/v1.0" + - $"/subscriptions/{serviceConfiguration.SubscriptionId}" + - $"/resourceGroups/{serviceConfiguration.ResourceGroupName}" + - $"/providers/Microsoft.MachineLearningServices/workspaces/{serviceConfiguration.ProjectName}"; + if (serviceConfiguration.IsHubBasedProject) + { + string discoveryUrl = + await GetServiceDiscoveryUrlAsync(evaluatorName, cancellationToken).ConfigureAwait(false); + + serviceUrl = + $"{discoveryUrl}/raisvc/v1.0" + + $"/subscriptions/{serviceConfiguration.SubscriptionId}" + + $"/resourceGroups/{serviceConfiguration.ResourceGroupName}" + + $"/providers/Microsoft.MachineLearningServices/workspaces/{serviceConfiguration.ProjectName}"; + } + else + { + serviceUrl = $"{serviceConfiguration.Endpoint.AbsoluteUri}/evaluations"; + } await EnsureServiceAvailabilityAsync( - serviceUrl, - capability: annotationTask, - evaluatorName, - cancellationToken).ConfigureAwait(false); + serviceUrl, + capability: annotationTask, + evaluatorName, + cancellationToken).ConfigureAwait(false); _ = _serviceUrlCache.TryAdd(key, serviceUrl); _serviceUrl = serviceUrl; @@ -196,7 +206,7 @@ private async ValueTask GetServiceDiscoveryUrlAsync( $"https://management.azure.com/subscriptions/{serviceConfiguration.SubscriptionId}" + $"/resourceGroups/{serviceConfiguration.ResourceGroupName}" + $"/providers/Microsoft.MachineLearningServices/workspaces/{serviceConfiguration.ProjectName}" + - $"?api-version=2023-08-01-preview"; + $"{APIVersionForServiceDiscoveryInHubBasedProjects}"; HttpResponseMessage response = await GetResponseAsync( @@ -244,7 +254,10 @@ private async ValueTask EnsureServiceAvailabilityAsync( string evaluatorName, CancellationToken cancellationToken) { - string serviceAvailabilityUrl = $"{serviceUrl}/checkannotation"; + string serviceAvailabilityUrl = + serviceConfiguration.IsHubBasedProject + ? $"{serviceUrl}/checkannotation" + : $"{serviceUrl}/checkannotation{APIVersionForNonHubBasedProjects}"; HttpResponseMessage response = await GetResponseAsync( @@ -297,7 +310,10 @@ private async ValueTask SubmitAnnotationRequestAsync( string evaluatorName, CancellationToken cancellationToken) { - string annotationUrl = $"{serviceUrl}/submitannotation"; + string annotationUrl = + serviceConfiguration.IsHubBasedProject + ? $"{serviceUrl}/submitannotation" + : $"{serviceUrl}/submitannotation{APIVersionForNonHubBasedProjects}"; HttpResponseMessage response = await GetResponseAsync( @@ -426,10 +442,13 @@ private async ValueTask AddHeadersAsync( httpRequestMessage.Headers.Add("User-Agent", userAgent); + TokenRequestContext context = + serviceConfiguration.IsHubBasedProject + ? new TokenRequestContext(scopes: ["https://management.azure.com/.default"]) + : new TokenRequestContext(scopes: ["https://ai.azure.com/.default"]); + AccessToken token = - await serviceConfiguration.Credential.GetTokenAsync( - new TokenRequestContext(scopes: ["https://management.azure.com/.default"]), - cancellationToken).ConfigureAwait(false); + await serviceConfiguration.Credential.GetTokenAsync(context, cancellationToken).ConfigureAwait(false); httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs index ec721fa59c7..06d4a045ced 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs @@ -6,64 +6,62 @@ // We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary // constructor syntax. +using System; +using System.Diagnostics.CodeAnalysis; using System.Net.Http; using Azure.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI.Evaluation.Safety; /// -/// Specifies configuration parameters such as the Azure AI project that should be used, and the credentials that -/// should be used, when a communicates with the Azure AI Foundry Evaluation +/// Specifies configuration parameters such as the Azure AI Foundry project that should be used, and the credentials +/// that should be used, when a communicates with the Azure AI Foundry Evaluation /// service to perform evaluations. /// -/// -/// The Azure that should be used when authenticating requests. -/// -/// -/// The ID of the Azure subscription that contains the project identified by . -/// -/// -/// The name of the Azure resource group that contains the project identified by . -/// -/// -/// The name of the Azure AI project. -/// -/// -/// The that should be used when communicating with the Azure AI Foundry Evaluation service. -/// While the parameter is optional, it is recommended to supply an that is configured with -/// robust resilience and retry policies. -/// -/// -/// The timeout (in seconds) after which a should stop retrying failed attempts -/// to communicate with the Azure AI Foundry Evaluation service when performing evaluations. -/// -public sealed class ContentSafetyServiceConfiguration( - TokenCredential credential, - string subscriptionId, - string resourceGroupName, - string projectName, - HttpClient? httpClient = null, - int timeoutInSecondsForRetries = 300) // 5 minutes +/// +/// +/// Note that Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also +/// known simply as Foundry projects). See https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/create-projects. +/// +/// +/// Hub-based projects are configured by specifying the , the +/// , and the for the project. Non-Hub-based projects, on the +/// other hand, are configured by specifying only the for the project. Use the appropriate +/// constructor overload to initialize based on the kind of project you +/// are working with. +/// +/// +public sealed class ContentSafetyServiceConfiguration { + private const int DefaultTimeoutInSecondsForRetries = 300; // 5 minutes + /// /// Gets the Azure that should be used when authenticating requests. /// - public TokenCredential Credential { get; } = credential; + public TokenCredential Credential { get; } + + /// + /// Gets the ID of the Azure subscription that contains the project identified by if the + /// project is a Hub-based project. + /// + public string? SubscriptionId { get; } /// - /// Gets the ID of the Azure subscription that contains the project identified by . + /// Gets the name of the Azure resource group that contains the project identified by if + /// the project is a Hub-based project. /// - public string SubscriptionId { get; } = subscriptionId; + public string? ResourceGroupName { get; } /// - /// Gets the name of the Azure resource group that contains the project identified by . + /// Gets the name of the Azure AI Foundry project if the project is a Hub-based project. /// - public string ResourceGroupName { get; } = resourceGroupName; + public string? ProjectName { get; } /// - /// Gets the name of the Azure AI project. + /// Gets the endpoint for the Azure AI Foundry project if the project is a non-Hub-based project. /// - public string ProjectName { get; } = projectName; + public Uri? Endpoint { get; } /// /// Gets the that should be used when communicating with the Azure AI Foundry Evaluation @@ -73,11 +71,155 @@ public sealed class ContentSafetyServiceConfiguration( /// While supplying an is optional, it is recommended to supply one that is configured /// with robust resilience and retry policies. /// - public HttpClient? HttpClient { get; } = httpClient; + public HttpClient? HttpClient { get; } /// /// Gets the timeout (in seconds) after which a should stop retrying failed /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. /// - public int TimeoutInSecondsForRetries { get; } = timeoutInSecondsForRetries; + public int TimeoutInSecondsForRetries { get; } + + [MemberNotNullWhen(true, nameof(SubscriptionId), nameof(ResourceGroupName), nameof(ProjectName))] + [MemberNotNullWhen(false, nameof(Endpoint))] + internal bool IsHubBasedProject => + !string.IsNullOrWhiteSpace(SubscriptionId) && + !string.IsNullOrWhiteSpace(ResourceGroupName) && + !string.IsNullOrWhiteSpace(ProjectName) && + Endpoint is null; + + /// + /// Initializes a new instance of the class for a Hub-based Azure + /// AI Foundry project with the specified . + /// + /// + /// The Azure that should be used when authenticating requests. + /// + /// + /// The ID of the Azure subscription that contains the Hub-based AI Foundry project identified by + /// . + /// + /// + /// The name of the Azure resource group that contains the Hub-based AI Foundry project identified by + /// . + /// + /// + /// The name of the Hub-based Azure AI Foundry project. + /// + /// + /// The that should be used when communicating with the Azure AI Foundry Evaluation + /// service. While the parameter is optional, it is recommended to supply an that is + /// configured with robust resilience and retry policies. + /// + /// + /// The timeout (in seconds) after which a should stop retrying failed + /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. + /// + /// + /// + /// Note that Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also + /// known simply as Foundry projects). See + /// https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/create-projects. + /// + /// + /// Use this constructor overload if you are working with a Hub-based project. + /// + /// + public ContentSafetyServiceConfiguration( + TokenCredential credential, + string subscriptionId, + string resourceGroupName, + string projectName, + HttpClient? httpClient = null, + int timeoutInSecondsForRetries = DefaultTimeoutInSecondsForRetries) + { + Credential = Throw.IfNull(credential); + SubscriptionId = Throw.IfNullOrWhitespace(subscriptionId); + ResourceGroupName = Throw.IfNullOrWhitespace(resourceGroupName); + ProjectName = Throw.IfNullOrWhitespace(projectName); + HttpClient = httpClient; + TimeoutInSecondsForRetries = timeoutInSecondsForRetries; + } + + /// + /// Initializes a new instance of the class for a non-Hub-based + /// Azure AI Foundry project with the specified . + /// + /// + /// The Azure that should be used when authenticating requests. + /// + /// + /// The endpoint for the non-Hub-based Azure AI Foundry project. + /// + /// + /// The that should be used when communicating with the Azure AI Foundry Evaluation + /// service. While the parameter is optional, it is recommended to supply an that is + /// configured with robust resilience and retry policies. + /// + /// + /// The timeout (in seconds) after which a should stop retrying failed + /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. + /// + /// + /// + /// Note that Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also + /// known simply as Foundry projects). See + /// https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/create-projects. + /// + /// + /// Use this constructor overload if you are working with a non-Hub-based project. + /// + /// + public ContentSafetyServiceConfiguration( + TokenCredential credential, + Uri endpoint, + HttpClient? httpClient = null, + int timeoutInSecondsForRetries = DefaultTimeoutInSecondsForRetries) + { + Credential = Throw.IfNull(credential); + Endpoint = Throw.IfNull(endpoint); + HttpClient = httpClient; + TimeoutInSecondsForRetries = timeoutInSecondsForRetries; + } + + /// + /// Initializes a new instance of the class for a non-Hub-based + /// Azure AI Foundry project with the specified . + /// + /// + /// The Azure that should be used when authenticating requests. + /// + /// + /// The endpoint URL for the non-Hub-based Azure AI Foundry project. + /// + /// + /// The that should be used when communicating with the Azure AI Foundry Evaluation + /// service. While the parameter is optional, it is recommended to supply an that is + /// configured with robust resilience and retry policies. + /// + /// + /// The timeout (in seconds) after which a should stop retrying failed + /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. + /// + /// + /// + /// Note that Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also + /// known simply as Foundry projects). See + /// https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/create-projects. + /// + /// + /// Use this constructor overload if you are working with a non-Hub-based project. + /// + /// + public ContentSafetyServiceConfiguration( + TokenCredential credential, + string endpointUrl, + HttpClient? httpClient = null, + int timeoutInSecondsForRetries = DefaultTimeoutInSecondsForRetries) + : this( + credential, + endpoint: new Uri(Throw.IfNullOrWhitespace(endpointUrl)), + httpClient, + timeoutInSecondsForRetries) + { + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs index 630adbffd8e..c0f955b8416 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs @@ -24,6 +24,7 @@ public class SafetyEvaluatorTests private static readonly ReportingConfiguration? _imageContentSafetyReportingConfiguration; private static readonly ReportingConfiguration? _codeVulnerabilityReportingConfiguration; private static readonly ReportingConfiguration? _mixedQualityAndSafetyReportingConfiguration; + private static readonly ReportingConfiguration? _hubBasedContentSafetyReportingConfiguration; static SafetyEvaluatorTests() { @@ -37,14 +38,11 @@ static SafetyEvaluatorTests() }; ChatConfiguration llmChatConfiguration = Setup.CreateChatConfiguration(); - ChatClientMetadata? clientMetadata = llmChatConfiguration.ChatClient.GetService(); string version = $"Product Version: {Constants.Version}"; string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; string projectName = $"Project: Integration Tests"; string testClass = $"Test Class: {nameof(SafetyEvaluatorTests)}"; - string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; - string model = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; string temperature = $"Temperature: {_chatOptions.Temperature}"; string usesContext = $"Feature: Context"; @@ -52,13 +50,17 @@ static SafetyEvaluatorTests() var contentSafetyServiceConfiguration = new ContentSafetyServiceConfiguration( credential, - subscriptionId: Settings.Current.AzureSubscriptionId, - resourceGroupName: Settings.Current.AzureResourceGroupName, - projectName: Settings.Current.AzureAIProjectName); + endpointUrl: Settings.Current.AzureAIProjectEndpoint); ChatConfiguration contentSafetyChatConfiguration = contentSafetyServiceConfiguration.ToChatConfiguration(llmChatConfiguration); + ChatClientMetadata? clientMetadata = + contentSafetyChatConfiguration.ChatClient.GetService(); + + string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; + string model = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; + IEvaluator hateAndUnfairnessEvaluator = new HateAndUnfairnessEvaluator(); IEvaluator selfHarmEvaluator = new SelfHarmEvaluator(); IEvaluator sexualEvaluator = new SexualEvaluator(); @@ -117,20 +119,65 @@ static SafetyEvaluatorTests() chatConfiguration: contentSafetyChatConfiguration, executionName: Constants.Version, tags: [version, date, projectName, testClass, provider, model, temperature]); + + var hubBasedContentSafetyServiceConfiguration = + new ContentSafetyServiceConfiguration( + credential, + subscriptionId: Settings.Current.AzureSubscriptionId, + resourceGroupName: Settings.Current.AzureResourceGroupName, + projectName: Settings.Current.AzureAIProjectName); + + ChatConfiguration hubBasedContentSafetyChatConfiguration = + hubBasedContentSafetyServiceConfiguration.ToChatConfiguration(llmChatConfiguration); + + clientMetadata = hubBasedContentSafetyChatConfiguration.ChatClient.GetService(); + + provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; + model = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; + + _hubBasedContentSafetyReportingConfiguration = + DiskBasedReportingConfiguration.Create( + storageRootPath: Settings.Current.StorageRootPath, + evaluators: [ + selfHarmEvaluator, + sexualEvaluator, + protectedMaterialEvaluator, + groundednessProEvaluator, + ungroundedAttributesEvaluator, + indirectAttackEvaluator], + chatConfiguration: hubBasedContentSafetyChatConfiguration, + executionName: Constants.Version, + tags: [version, date, projectName, testClass, provider, model, temperature, usesContext]); } } [ConditionalFact] - public async Task EvaluateConversationWithSingleTurn() + public async Task EvaluateConversationWithSingleTurn_HubBasedProject() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _hubBasedContentSafetyReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithSingleTurn_HubBasedProject)}"); + + await EvaluateConversationWithSingleTurn(scenarioRun); + } + + [ConditionalFact] + public async Task EvaluateConversationWithSingleTurn_NonHubBasedProject() { SkipIfNotConfigured(); await using ScenarioRun scenarioRun = await _contentSafetyReportingConfiguration.CreateScenarioRunAsync( - scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithSingleTurn)}"); + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithSingleTurn_NonHubBasedProject)}"); - IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; + await EvaluateConversationWithSingleTurn(scenarioRun); + } + private static async Task EvaluateConversationWithSingleTurn(ScenarioRun scenarioRun) + { + IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; var messages = new List(); string systemPrompt = @@ -183,16 +230,32 @@ The distance varies due to the elliptical orbits of both planets. } [ConditionalFact] - public async Task EvaluateConversationWithMultipleTurns() + public async Task EvaluateConversationWithMultipleTurns_HubBasedProject() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _hubBasedContentSafetyReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithMultipleTurns_HubBasedProject)}"); + + await EvaluateConversationWithMultipleTurns(scenarioRun); + } + + [ConditionalFact] + public async Task EvaluateConversationWithMultipleTurns_NonHubBasedProject() { SkipIfNotConfigured(); await using ScenarioRun scenarioRun = await _contentSafetyReportingConfiguration.CreateScenarioRunAsync( - scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithMultipleTurns)}"); + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithMultipleTurns_NonHubBasedProject)}"); - IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; + await EvaluateConversationWithMultipleTurns(scenarioRun); + } + private static async Task EvaluateConversationWithMultipleTurns(ScenarioRun scenarioRun) + { + IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; var messages = new List(); string systemPrompt = @@ -553,6 +616,7 @@ await _mixedQualityAndSafetyReportingConfiguration.CreateScenarioRunAsync( [MemberNotNull(nameof(_imageContentSafetyReportingConfiguration))] [MemberNotNull(nameof(_codeVulnerabilityReportingConfiguration))] [MemberNotNull(nameof(_mixedQualityAndSafetyReportingConfiguration))] + [MemberNotNull(nameof(_hubBasedContentSafetyReportingConfiguration))] private static void SkipIfNotConfigured() { if (!Settings.Current.Configured) @@ -565,5 +629,6 @@ private static void SkipIfNotConfigured() Assert.NotNull(_codeVulnerabilityReportingConfiguration); Assert.NotNull(_imageContentSafetyReportingConfiguration); Assert.NotNull(_mixedQualityAndSafetyReportingConfiguration); + Assert.NotNull(_hubBasedContentSafetyReportingConfiguration); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs index 22e027e73b2..25ee9d544cb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs @@ -16,6 +16,7 @@ public class Settings public string AzureSubscriptionId { get; } public string AzureResourceGroupName { get; } public string AzureAIProjectName { get; } + public string AzureAIProjectEndpoint { get; } public Settings(IConfiguration config) { @@ -49,6 +50,10 @@ public Settings(IConfiguration config) AzureAIProjectName = config.GetValue("AzureAIProjectName") ?? throw new ArgumentNullException(nameof(AzureAIProjectName)); + + AzureAIProjectEndpoint = + config.GetValue("AzureAIProjectEndpoint") + ?? throw new ArgumentNullException(nameof(AzureAIProjectEndpoint)); #pragma warning restore CA2208 } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json index 63b5ed0d33c..24e079d421d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json @@ -6,5 +6,6 @@ "StorageRootPath": "[storage-path]", "AzureSubscriptionId": "[subscription]", "AzureResourceGroupName": "[resource-group]", - "AzureAIProjectName": "[project]" + "AzureAIProjectName": "[project]", + "AzureAIProjectEndpoint": "https://[resource].services.ai.azure.com/api/projects/[project]" } From e717e4af0066af7c3e81b20b66b856ab8f26cb5f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 17 Jul 2025 11:21:09 +0100 Subject: [PATCH 36/71] Bump (#6624) --- eng/packages/General.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/packages/General.props b/eng/packages/General.props index aa9771bfb4a..a6c1a69f4d8 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -4,7 +4,7 @@ - + From 59b07255e4f6a6224fbdabb004aea7ebf57408fc Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 18 Jul 2025 06:36:08 -0400 Subject: [PATCH 37/71] Add DataContent.Name property (#6616) * Add DataContent.Name property Enables a name (e.g. file name) to optionally be associated with a DataContent. DataContent is often used to represent named entities, such as when uploading files. --- .../Contents/AIAnnotation.cs | 0 .../Contents/AIAnnotationExtensions.cs | 0 .../Contents/AIAnnotationKind.cs | 0 .../Contents/AIAnnotationReference.cs | 0 .../Contents/DataContent.cs | 7 ++++++ .../Microsoft.Extensions.AI.Abstractions.json | 4 ++++ .../OpenAIChatClient.cs | 2 +- .../OpenAIResponseChatClient.cs | 2 +- .../Contents/AIAnnotationReferenceTests.cs | 0 .../Contents/AIAnnotationTests.cs | 0 .../Contents/AIContentAnnotationTests.cs | 0 .../Contents/DataContentTests.cs | 24 +++++++++++++++---- .../ChatClientIntegrationTests.cs | 2 +- 13 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationKind.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationReference.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationReferenceTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentAnnotationTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationExtensions.cs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationKind.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationKind.cs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationReference.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationReference.cs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs index 5bbde1e1444..57f7fac1962 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -183,6 +183,13 @@ public string Uri [JsonIgnore] public string MediaType { get; } + /// Gets or sets an optional name associated with the data. + /// + /// A service might use this name as part of citations or to help infer the type of data + /// being represented based on a file extension. + /// + public string? Name { get; set; } + /// Gets the data represented by this instance. /// /// If the instance was constructed from a , this property returns that data. 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 b47765269af..4d190fccda8 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 @@ -1349,6 +1349,10 @@ "Member": "System.ReadOnlyMemory Microsoft.Extensions.AI.DataContent.Data { get; }", "Stage": "Stable" }, + { + "Member": "string? Microsoft.Extensions.AI.DataContent.Name { get; set; }", + "Stage": "Stable" + }, { "Member": "string Microsoft.Extensions.AI.DataContent.MediaType { get; }", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 394fccad1b6..52dfc3dc8af 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -264,7 +264,7 @@ private static List ToOpenAIChatContent(IList break; case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): - return ChatMessageContentPart.CreateFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, $"{Guid.NewGuid():N}.pdf"); + return ChatMessageContentPart.CreateFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf"); case AIContent when content.RawRepresentation is ChatMessageContentPart rawContentPart: return rawContentPart; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index c4a1261844c..57ed46721b0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -639,7 +639,7 @@ private static List ToOpenAIResponsesContent(IList([0x01, 0x02, 0x03, 0x04]), "application/octet-stream"), + JsonSerializer.Serialize( + new DataContent(new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream"), + TestJsonSerializerContext.Default.Options)); + + Assert.Equal( + """{"uri":"data:application/octet-stream;base64,AQIDBA==","name":"test.bin"}""", + JsonSerializer.Serialize( + new DataContent(new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream") { Name = "test.bin" }, TestJsonSerializerContext.Default.Options)); } @@ -260,4 +267,13 @@ public void NonBase64Data_Normalized() Assert.Equal("aGVsbG8gd29ybGQ=", content.Base64Data.ToString()); Assert.Equal("hello world", Encoding.ASCII.GetString(content.Data.ToArray())); } + + [Fact] + public void FileName_Roundtrips() + { + DataContent content = new(new byte[] { 1, 2, 3 }, "application/octet-stream"); + Assert.Null(content.Name); + content.Name = "test.bin"; + Assert.Equal("test.bin", content.Name); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index ffa94f64531..c9f0e09abf3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -202,7 +202,7 @@ public virtual async Task MultiModal_DescribePdf() new(ChatRole.User, [ new TextContent("What text does this document contain?"), - new DataContent(ImageDataUri.GetPdfDataUri(), "application/pdf"), + new DataContent(ImageDataUri.GetPdfDataUri(), "application/pdf") { Name = "sample.pdf" }, ]) ], new() { ModelId = GetModel_MultiModal_DescribeImage() }); From 29065e75bd28380f265cc8f3c5e81926ac97fbf8 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 18 Jul 2025 08:57:31 -0400 Subject: [PATCH 38/71] Fix handling of multiple responses messages (#6627) --- .../OpenAIResponseChatClient.cs | 7 +- .../OpenAIResponseClientTests.cs | 116 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index 57ed46721b0..b5ba36b5e1a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -111,10 +111,15 @@ public async Task GetResponseAsync( switch (outputItem) { case MessageResponseItem messageItem: + if (message.MessageId is not null && message.MessageId != messageItem.Id) + { + message = new ChatMessage(); + response.Messages.Add(message); + } + message.MessageId = messageItem.Id; message.RawRepresentation = messageItem; message.Role = ToChatRole(messageItem.Role); - (message.AdditionalProperties ??= []).Add(nameof(messageItem.Id), messageItem.Id); ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); break; diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index b98eb89197f..72df6002ca2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -479,6 +479,122 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio Assert.Equal("Hello! How can I assist you today?", response.Text); } + [Fact] + public async Task MultipleOutputItems_NonStreaming() + { + 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_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", + "object": "response", + "created_at": 1741891428, + "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_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello!", + "annotations": [] + } + ] + }, + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a182e", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": " How can I assist you today?", + "annotations": [] + } + ] + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "generate_summary": null + }, + "store": true, + "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"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + }); + Assert.NotNull(response); + + Assert.Equal("resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", response.ResponseId); + Assert.Equal("resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_741_891_428), response.CreatedAt); + Assert.Null(response.FinishReason); + + Assert.Equal(2, response.Messages.Count); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("Hello!", response.Messages[0].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + Assert.Equal(" How can I assist you today?", response.Messages[1].Text); + + Assert.NotNull(response.Usage); + Assert.Equal(26, response.Usage.InputTokenCount); + Assert.Equal(10, response.Usage.OutputTokenCount); + Assert.Equal(36, response.Usage.TotalTokenCount); + } + /// Converts an Extensions function to an OpenAI response chat tool. private static ResponseTool ToOpenAIResponseChatTool(AIFunction aiFunction) { From b8012f4eba94dde545ea8c0bf5a15034a55cbc9b Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 19 Jul 2025 03:52:14 -0400 Subject: [PATCH 39/71] Expose additional chat model conversion helpers (#6629) - Exposes the conversion routines for converting OpenAI output responses - Moves the extensions into the namespace most relevant to the performed operation --- ...crosoftExtensionsAIAssistantsExtensions.cs | 19 ++++++ .../MicrosoftExtensionsAIChatExtensions.cs | 33 ++++++++++ ...MicrosoftExtensionsAIRealtimeExtensions.cs | 19 ++++++ ...icrosoftExtensionsAIResponsesExtensions.cs | 33 ++++++++++ ...lient.cs => OpenAIAssistantsChatClient.cs} | 15 +++-- .../OpenAIChatClient.cs | 18 +++-- .../OpenAIClientExtensions.cs | 65 +++++-------------- .../OpenAIEmbeddingGenerator.cs | 1 - ...Client.cs => OpenAIResponsesChatClient.cs} | 14 ++-- .../OpenAIAssistantChatClientTests.cs | 3 +- .../OpenAIChatClientTests.cs | 4 +- .../OpenAIConversionTests.cs | 33 ++++++++++ 12 files changed, 186 insertions(+), 71 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs rename src/Libraries/Microsoft.Extensions.AI.OpenAI/{OpenAIAssistantChatClient.cs => OpenAIAssistantsChatClient.cs} (96%) rename src/Libraries/Microsoft.Extensions.AI.OpenAI/{OpenAIResponseChatClient.cs => OpenAIResponsesChatClient.cs} (98%) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs new file mode 100644 index 00000000000..900883c6d43 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.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 Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Assistants; + +/// Provides extension methods for working with content associated with OpenAI.Assistants. +public static class MicrosoftExtensionsAIAssistantsExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) => + OpenAIAssistantsChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs new file mode 100644 index 00000000000..c7eb3b2e03e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.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.Collections.Generic; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Chat; + +/// Provides extension methods for working with content associated with OpenAI.Chat. +public static class MicrosoftExtensionsAIChatExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ChatTool AsOpenAIChatTool(this AIFunction function) => + OpenAIChatClient.ToOpenAIChatTool(Throw.IfNull(function)); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// A sequence of OpenAI chat messages. + public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages) => + OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null); + + /// Creates a Microsoft.Extensions.AI from a . + /// The to convert to a . + /// The options employed in the creation of the response. + /// A converted . + public static ChatResponse AsChatResponse(this ChatCompletion chatCompletion, ChatCompletionOptions? options = null) => + OpenAIChatClient.FromOpenAIChatCompletion(Throw.IfNull(chatCompletion), options); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs new file mode 100644 index 00000000000..ad180e96b1e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.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 Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Realtime; + +/// Provides extension methods for working with content associated with OpenAI.Realtime. +public static class MicrosoftExtensionsAIRealtimeExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => + OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs new file mode 100644 index 00000000000..e54b3092c5a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.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.Collections.Generic; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Responses; + +/// Provides extension methods for working with content associated with OpenAI.Responses. +public static class MicrosoftExtensionsAIResponsesExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => + OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(function)); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// A sequence of OpenAI response items. + public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages) => + OpenAIResponsesChatClient.ToOpenAIResponseItems(Throw.IfNull(messages)); + + /// Creates a Microsoft.Extensions.AI from an . + /// The to convert to a . + /// The options employed in the creation of the response. + /// A converted . + public static ChatResponse AsChatResponse(this OpenAIResponse response, ResponseCreationOptions? options = null) => + OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs similarity index 96% rename from src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs rename to src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs index c1d59e30a68..46bc39ee278 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs @@ -26,8 +26,8 @@ namespace Microsoft.Extensions.AI; -/// Represents an for an Azure.AI.Agents.Persistent . -internal sealed class OpenAIAssistantChatClient : IChatClient +/// Represents an for an OpenAI . +internal sealed class OpenAIAssistantsChatClient : IChatClient { /// The underlying . private readonly AssistantClient _client; @@ -44,8 +44,8 @@ internal sealed class OpenAIAssistantChatClient : IChatClient /// List of tools associated with the assistant. private IReadOnlyList? _assistantTools; - /// Initializes a new instance of the class for the specified . - public OpenAIAssistantChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId) + /// Initializes a new instance of the class for the specified . + public OpenAIAssistantsChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId) { _client = Throw.IfNull(assistantClient); _assistantId = Throw.IfNullOrWhitespace(assistantId); @@ -62,6 +62,13 @@ public OpenAIAssistantChatClient(AssistantClient assistantClient, string assista _metadata = new("openai", providerUrl); } + /// Initializes a new instance of the class for the specified . + public OpenAIAssistantsChatClient(AssistantClient assistantClient, Assistant assistant, string? defaultThreadId) + : this(assistantClient, Throw.IfNull(assistant).Id, defaultThreadId) + { + _assistantTools = assistant.Tools; + } + /// public object? GetService(Type serviceType, object? serviceKey = null) => serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) : diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 52dfc3dc8af..9fc2f3cbeef 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -17,6 +17,7 @@ #pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) #pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable SA1202 // Elements should be ordered by access #pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; @@ -76,7 +77,7 @@ public async Task GetResponseAsync( // Make the call to OpenAI. var response = await _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken).ConfigureAwait(false); - return FromOpenAIChatCompletion(response.Value, options, openAIOptions); + return FromOpenAIChatCompletion(response.Value, openAIOptions); } /// @@ -430,7 +431,7 @@ private static string GetOutputAudioMimeType(ChatCompletionOptions? options) => "mp3" or _ => "audio/mpeg", }; - private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatOptions? options, ChatCompletionOptions chatCompletionOptions) + internal static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatCompletionOptions? chatCompletionOptions) { _ = Throw.IfNull(openAICompletion); @@ -461,17 +462,14 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple } // Also manufacture function calling content items from any tool calls in the response. - if (options?.Tools is { Count: > 0 }) + foreach (ChatToolCall toolCall in openAICompletion.ToolCalls) { - foreach (ChatToolCall toolCall in openAICompletion.ToolCalls) + if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) { - if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) - { - var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); - callContent.RawRepresentation = toolCall; + var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + callContent.RawRepresentation = toolCall; - returnMessage.Contents.Add(callContent); - } + returnMessage.Contents.Add(callContent); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 9f42fa88773..889998cc933 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -8,20 +8,15 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Microsoft.Shared.Diagnostics; using OpenAI; using OpenAI.Assistants; using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Embeddings; -using OpenAI.Realtime; using OpenAI.Responses; #pragma warning disable S103 // Lines should not be too long -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable SA1515 // Single-line comment should be preceded by blank line -#pragma warning disable CA1305 // Specify IFormatProvider -#pragma warning disable S1135 // Track uses of "TODO" tags namespace Microsoft.Extensions.AI; @@ -120,7 +115,7 @@ public static IChatClient AsIChatClient(this ChatClient chatClient) => /// An that can be used to converse via the . /// is . public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient) => - new OpenAIResponseChatClient(responseClient); + new OpenAIResponsesChatClient(responseClient); /// Gets an for use with this . /// The instance to be accessed as an . @@ -135,7 +130,21 @@ public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient /// is . /// is empty or composed entirely of whitespace. public static IChatClient AsIChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) => - new OpenAIAssistantChatClient(assistantClient, assistantId, threadId); + new OpenAIAssistantsChatClient(assistantClient, assistantId, threadId); + + /// Gets an for use with this . + /// The instance to be accessed as an . + /// The with which to interact. + /// + /// An optional existing thread identifier for the chat session. This serves as a default, and may be overridden per call to + /// or via the + /// property. If no thread ID is provided via either mechanism, a new thread will be created for the request. + /// + /// An instance configured to interact with the specified agent and thread. + /// is . + /// is . + public static IChatClient AsIChatClient(this AssistantClient assistantClient, Assistant assistant, string? threadId = null) => + new OpenAIAssistantsChatClient(assistantClient, assistant, threadId); /// Gets an for use with this . /// The client. @@ -153,48 +162,6 @@ public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioCl public static IEmbeddingGenerator> AsIEmbeddingGenerator(this EmbeddingClient embeddingClient, int? defaultModelDimensions = null) => new OpenAIEmbeddingGenerator(embeddingClient, defaultModelDimensions); - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static ChatTool AsOpenAIChatTool(this AIFunction function) => - OpenAIChatClient.ToOpenAIChatTool(Throw.IfNull(function)); - - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) => - OpenAIAssistantChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); - - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => - OpenAIResponseChatClient.ToResponseTool(Throw.IfNull(function)); - - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => - OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); - - /// Creates a sequence of OpenAI instances from the specified input messages. - /// The input messages to convert. - /// A sequence of OpenAI chat messages. - public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages) => - OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null); - - /// Creates a sequence of OpenAI instances from the specified input messages. - /// The input messages to convert. - /// A sequence of OpenAI response items. - public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages) => - OpenAIResponseChatClient.ToOpenAIResponseItems(Throw.IfNull(messages)); - - // TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict. - /// Gets whether the properties specify that strict schema handling is desired. internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index 004f6274265..a6c4af831ba 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -8,7 +8,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -using OpenAI; using OpenAI.Embeddings; #pragma warning disable S1067 // Expressions should not be too complex diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs similarity index 98% rename from src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs rename to src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index b5ba36b5e1a..60d0e10a159 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -23,7 +23,7 @@ namespace Microsoft.Extensions.AI; /// Represents an for an . -internal sealed class OpenAIResponseChatClient : IChatClient +internal sealed class OpenAIResponsesChatClient : IChatClient { /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -31,10 +31,10 @@ internal sealed class OpenAIResponseChatClient : IChatClient /// The underlying . private readonly OpenAIResponseClient _responseClient; - /// Initializes a new instance of the class for the specified . + /// Initializes a new instance of the class for the specified . /// The underlying client. /// is . - public OpenAIResponseChatClient(OpenAIResponseClient responseClient) + public OpenAIResponsesChatClient(OpenAIResponseClient responseClient) { _ = Throw.IfNull(responseClient); @@ -78,10 +78,16 @@ public async Task GetResponseAsync( // Make the call to the OpenAIResponseClient. var openAIResponse = (await _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + // Convert the response to a ChatResponse. + return FromOpenAIResponse(openAIResponse, openAIOptions); + } + + internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions) + { // Convert and return the results. ChatResponse response = new() { - ConversationId = openAIOptions.StoredOutputEnabled is false ? null : openAIResponse.Id, + ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : openAIResponse.Id, CreatedAt = openAIResponse.CreatedAt, FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), Messages = [new(ChatRole.Assistant, [])], diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs index 3b084b5ec8a..7779e4cf18d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs @@ -19,7 +19,8 @@ public class OpenAIAssistantChatClientTests public void AsIChatClient_InvalidArgs_Throws() { Assert.Throws("assistantClient", () => ((AssistantClient)null!).AsIChatClient("assistantId")); - Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient(null!)); + Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient((string)null!)); + Assert.Throws("assistant", () => new AssistantClient("ignored").AsIChatClient((Assistant)null!)); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index d06d8f520be..640dab54f8e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -398,7 +398,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); + openAIOptions.Tools.Add(tool.AsOpenAIChatTool()); return openAIOptions; }, ModelId = null, @@ -475,7 +475,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); + openAIOptions.Tools.Add(tool.AsOpenAIChatTool()); return openAIOptions; }, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 951554eda75..73f3ae65548 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -183,4 +183,37 @@ public void AsOpenAIResponseItems_ProducesExpectedOutput() Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m5.Role); Assert.Equal("The answer is 42.", Assert.Single(m5.Content).Text); } + + [Fact] + public void AsChatResponse_ConvertsOpenAIChatCompletion() + { + Assert.Throws("chatCompletion", () => ((ChatCompletion)null!).AsChatResponse()); + + ChatCompletion cc = OpenAIChatModelFactory.ChatCompletion( + "id", OpenAI.Chat.ChatFinishReason.Length, null, null, + [ChatToolCall.CreateFunctionToolCall("id", "functionName", BinaryData.FromString("test"))], + ChatMessageRole.User, null, null, null, new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + "model123", null, OpenAIChatModelFactory.ChatTokenUsage(2, 1, 3)); + cc.Content.Add(ChatMessageContentPart.CreateTextPart("Hello, world!")); + cc.Content.Add(ChatMessageContentPart.CreateImagePart(new Uri("http://example.com/image.png"))); + + ChatResponse response = cc.AsChatResponse(); + + Assert.Equal("id", response.ResponseId); + Assert.Equal(ChatFinishReason.Length, response.FinishReason); + Assert.Equal("model123", response.ModelId); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), response.CreatedAt); + Assert.NotNull(response.Usage); + Assert.Equal(1, response.Usage.InputTokenCount); + Assert.Equal(2, response.Usage.OutputTokenCount); + Assert.Equal(3, response.Usage.TotalTokenCount); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.User, message.Role); + + Assert.Equal(3, message.Contents.Count); + Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0]).Text); + Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1]).Uri.ToString()); + Assert.Equal("functionName", Assert.IsType(message.Contents[2]).Name); + } } From 0b17d02db4f4ce6d5638182bffe90f3a091d68f7 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:31:48 +0000 Subject: [PATCH 40/71] Update dependencies from https://github.com/dotnet/arcade build 20250716.1 (#6633) [main] Update dependencies from dotnet/arcade --- eng/Version.Details.xml | 12 ++++++------ eng/Versions.props | 2 +- eng/common/tools.ps1 | 2 +- global.json | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index cb6ead9fa88..324779fef3a 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -194,17 +194,17 @@ - + https://github.com/dotnet/arcade - 13b20849f8294593bf150a801cab639397e6c29d + 1a2e280a031aaed0dca606ec8c59c6fe0f9bfc7f - + https://github.com/dotnet/arcade - 13b20849f8294593bf150a801cab639397e6c29d + 1a2e280a031aaed0dca606ec8c59c6fe0f9bfc7f - + https://github.com/dotnet/arcade - 13b20849f8294593bf150a801cab639397e6c29d + 1a2e280a031aaed0dca606ec8c59c6fe0f9bfc7f diff --git a/eng/Versions.props b/eng/Versions.props index 1eb4594d5af..d849e7ffed6 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -84,7 +84,7 @@ 9.0.7 - 9.0.0-beta.25325.4 + 9.0.0-beta.25366.1 diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index 22b49e09d09..9b3ad8840fd 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -416,7 +416,7 @@ function InitializeVisualStudioMSBuild([bool]$install, [object]$vsRequirements = # Locate Visual Studio installation or download x-copy msbuild. $vsInfo = LocateVisualStudio $vsRequirements - if ($vsInfo -ne $null) { + if ($vsInfo -ne $null -and $env:ForceUseXCopyMSBuild -eq $null) { # Ensure vsInstallDir has a trailing slash $vsInstallDir = Join-Path $vsInfo.installationPath "\" $vsMajorVersion = $vsInfo.installationVersion.Split('.')[0] diff --git a/global.json b/global.json index 70681bc9744..945f04c1f57 100644 --- a/global.json +++ b/global.json @@ -18,7 +18,7 @@ "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.0", "Microsoft.Build.Traversal": "3.2.0", - "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.25325.4", - "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.25325.4" + "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.25366.1", + "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.25366.1" } } From 006a5696a88e4c8761017949dc1ea0a809a4feff Mon Sep 17 00:00:00 2001 From: Iliar Turdushev Date: Tue, 22 Jul 2025 12:48:48 +0200 Subject: [PATCH 41/71] Fixes #6548 (#6618) Adds an attempt to retrieve the request message from the resilience context in addition to retrieving the request from the response --- .../Polly/HttpRetryStrategyOptionsExtensions.cs | 8 +++++--- .../HttpRetryStrategyOptionsExtensionsTests.cs | 16 +++++++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs index 85168988c7b..becd53e1240 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using Polly; +using Polly.Retry; namespace Microsoft.Extensions.Http.Resilience; @@ -52,9 +53,7 @@ public static void DisableFor(this HttpRetryStrategyOptions options, params Http { var result = await shouldHandle(args).ConfigureAwait(args.Context.ContinueOnCapturedContext); - if (result && - args.Outcome.Result is HttpResponseMessage response && - response.RequestMessage is HttpRequestMessage request) + if (result && GetRequestMessage(args) is HttpRequestMessage request) { return !methods.Contains(request.Method); } @@ -62,5 +61,8 @@ args.Outcome.Result is HttpResponseMessage response && return result; }; } + + private static HttpRequestMessage? GetRequestMessage(RetryPredicateArguments args) => + args.Outcome.Result?.RequestMessage ?? args.Context.GetRequestMessage(); } diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs index 4d43c020d1f..996731b08bf 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs @@ -65,12 +65,16 @@ public async Task DisableFor_RespectsOriginalShouldHandlePredicate() } [Fact] - public async Task DisableFor_ResponseMessageIsNull_DoesNotDisableRetries() + public async Task DisableFor_ResponseMessageIsNull_RetrievesRequestMessageFromContext() { var options = new HttpRetryStrategyOptions { ShouldHandle = _ => PredicateResult.True() }; options.DisableFor(HttpMethod.Post); - Assert.True(await options.ShouldHandle(CreatePredicateArguments(null))); + using var request = new HttpRequestMessage { Method = HttpMethod.Post }; + var context = ResilienceContextPool.Shared.Get(); + context.SetRequestMessage(request); + + Assert.False(await options.ShouldHandle(CreatePredicateArguments(null, context))); } [Fact] @@ -80,8 +84,10 @@ public async Task DisableFor_RequestMessageIsNull_DoesNotDisableRetries() options.DisableFor(HttpMethod.Post); using var response = new HttpResponseMessage { RequestMessage = null }; + var context = ResilienceContextPool.Shared.Get(); + context.SetRequestMessage(null); - Assert.True(await options.ShouldHandle(CreatePredicateArguments(response))); + Assert.True(await options.ShouldHandle(CreatePredicateArguments(response, context))); } [Theory] @@ -105,10 +111,10 @@ public async Task DisableForUnsafeHttpMethods_PositiveScenario(string httpMethod Assert.Equal(shouldHandle, await options.ShouldHandle(CreatePredicateArguments(response))); } - private static RetryPredicateArguments CreatePredicateArguments(HttpResponseMessage? response) + private static RetryPredicateArguments CreatePredicateArguments(HttpResponseMessage? response, ResilienceContext? context = null) { return new RetryPredicateArguments( - ResilienceContextPool.Shared.Get(), + context ?? ResilienceContextPool.Shared.Get(), Outcome.FromResult(response), attemptNumber: 1); } From 5d8efb684ac5a56965f6fb72037cbd1db51ad74e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 23 Jul 2025 19:50:45 +0000 Subject: [PATCH 42/71] Bump Package validation baseline version to 9.7.0 (#6650) * Initial plan * Bump Package validation baseline version to 9.7.0 Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --- eng/MSBuild/Packaging.targets | 2 +- eng/Versions.props | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/eng/MSBuild/Packaging.targets b/eng/MSBuild/Packaging.targets index fdde9aa70d1..e5cd9bdf4c2 100644 --- a/eng/MSBuild/Packaging.targets +++ b/eng/MSBuild/Packaging.targets @@ -37,7 +37,7 @@ true - $(ApiCompatBaselineVersion) + 9.7.0 diff --git a/eng/Versions.props b/eng/Versions.props index d849e7ffed6..67d58f467f3 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -6,7 +6,6 @@ preview 1 $(MajorVersion).$(MinorVersion).$(PatchVersion) - 9.6.0 $(MajorVersion).$(MinorVersion).0.0 true - 9.7.0 + 9.7.0 @@ -93,7 +93,7 @@ !@(_PackageBuildFile->AnyHaveMetadataValue('PackagePathWithoutFilename', '$(_NETStandardCompatErrorPlaceholderFilePackagePath)'))" /> - + @@ -102,4 +102,15 @@ + + + + <_PackageVersionInfo Include="$(MSBuildProjectFullPath)"> + $(PackageVersion) + $(PackageId) + + + + diff --git a/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj b/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj index cead17cde9e..e8df485098b 100644 --- a/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj +++ b/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj @@ -18,13 +18,42 @@ IsImplicitlyDefined="true" /> + + + + + + + + <_ResolvedPackageVersionVariableReference Include="@(_ResolvedPackageVersionInfo)"> + TemplatePackageVersion_$([System.String]::Copy('%(PackageId)').Replace('.', '')) + + + + + + $(GeneratedContentProperties); + + @(_ResolvedPackageVersionVariableReference->'%(VersionVariableName)=%(PackageVersion)') + + + + + DependsOnTargets="ComputeGeneratedContentProperties;_GetPackageVersionVariables"> diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index c738ee0b547..706424bdc25 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -13,20 +13,25 @@ <_McpServerContentRoot>$(MSBuildThisFileDirectory)Microsoft.Extensions.AI.Templates\src\McpServer\ - + - - $(Version) - $(Version) - $(Version) - + Specifies packages defined in this repo that get referenced in generated template content. + For each item specified below, a property will be generated whose name matches the format: + "TemplatePackageVersion_{PackageName}" + where {PackageName} is the package ID with '.' characters removed. + The value of each property will be the computed package version. + --> + + + + + + - + + 9.3.0 9.3.0-preview.1.25265.20 @@ -47,8 +52,6 @@ - <_TemplateUsingJustBuiltPackages Condition="'$(TemplatePackageVersion_MicrosoftExtensionsAI)' == '$(Version)' OR '$(TemplatePackageVersion_MicrosoftExtensionsAI_Preview)' == '$(Version)'">true - $(GeneratedContentProperties); @@ -57,9 +60,6 @@ ArtifactsShippingPackagesDir=$(ArtifactsShippingPackagesDir); - TemplatePackageVersion_MicrosoftExtensionsAI=$(TemplatePackageVersion_MicrosoftExtensionsAI); - TemplatePackageVersion_MicrosoftExtensionsAI_Preview=$(TemplatePackageVersion_MicrosoftExtensionsAI_Preview); - TemplatePackageVersion_MicrosoftExtensionsHttpResilience=$(TemplatePackageVersion_MicrosoftExtensionsHttpResilience); TemplatePackageVersion_Aspire=$(TemplatePackageVersion_Aspire); TemplatePackageVersion_Aspire_Preview=$(TemplatePackageVersion_Aspire_Preview); TemplatePackageVersion_AzureAIOpenAI=$(TemplatePackageVersion_AzureAIOpenAI); @@ -79,7 +79,6 @@ LocalChatTemplateVariant=$(_LocalChatTemplateVariant); - UsingJustBuiltPackages=$(_TemplateUsingJustBuiltPackages); @@ -108,18 +107,9 @@ - - - <_GeneratedContentEnablingJustBuiltPackages + - - - diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in index 46d7382d6f1..64d585fd7a6 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in @@ -14,19 +14,19 @@ - + - + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in index 670604290b9..08d54995389 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in @@ -4,13 +4,8 @@ It will not get included in the built project template. --> - - <_UsingJustBuiltPackages>${UsingJustBuiltPackages} - - Date: Thu, 24 Jul 2025 16:43:48 -0400 Subject: [PATCH 44/71] Remove Microsoft.Extensions.AI.Ollama (#6655) --- .../JsonContext.cs | 24 - .../Microsoft.Extensions.AI.Ollama.csproj | 44 -- .../Microsoft.Extensions.AI.Ollama.json | 0 .../OllamaChatClient.cs | 505 ------------------ .../OllamaChatRequest.cs | 17 - .../OllamaChatRequestMessage.cs | 13 - .../OllamaChatResponse.cs | 20 - .../OllamaChatResponseMessage.cs | 11 - .../OllamaEmbeddingGenerator.cs | 165 ------ .../OllamaEmbeddingRequest.cs | 13 - .../OllamaEmbeddingResponse.cs | 21 - .../OllamaFunctionCallContent.cs | 13 - .../OllamaFunctionResultContent.cs | 12 - .../OllamaFunctionTool.cs | 11 - .../OllamaFunctionToolCall.cs | 12 - .../OllamaFunctionToolParameter.cs | 13 - .../OllamaFunctionToolParameters.cs | 14 - .../OllamaRequestOptions.cs | 41 -- .../OllamaTool.cs | 10 - .../OllamaToolCall.cs | 9 - .../OllamaUtilities.cs | 72 --- .../Microsoft.Extensions.AI.Ollama/README.md | 11 - ...icrosoft.Extensions.AI.Ollama.Tests.csproj | 22 - .../OllamaChatClientIntegrationTests.cs | 115 ---- .../OllamaChatClientTests.cs | 487 ----------------- ...llamaEmbeddingGeneratorIntegrationTests.cs | 32 -- .../OllamaEmbeddingGeneratorTests.cs | 102 ---- .../TestJsonSerializerContext.cs | 12 - .../IntegrationTestHelpers.cs | 0 ...ns.AI.OllamaSharp.Integration.Tests.csproj | 2 +- .../OllamaSharpChatClientIntegrationTests.cs | 103 +++- ...SharpEmbeddingGeneratorIntegrationTests.cs | 23 +- 32 files changed, 125 insertions(+), 1824 deletions(-) delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.json delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/README.md delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs rename test/Libraries/{Microsoft.Extensions.AI.Ollama.Tests => Microsoft.Extensions.AI.OllamaSharp.Integration.Tests}/IntegrationTestHelpers.cs (100%) diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs deleted file mode 100644 index 6de0144c7cf..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] -[JsonSerializable(typeof(OllamaChatRequest))] -[JsonSerializable(typeof(OllamaChatRequestMessage))] -[JsonSerializable(typeof(OllamaChatResponse))] -[JsonSerializable(typeof(OllamaChatResponseMessage))] -[JsonSerializable(typeof(OllamaFunctionCallContent))] -[JsonSerializable(typeof(OllamaFunctionResultContent))] -[JsonSerializable(typeof(OllamaFunctionTool))] -[JsonSerializable(typeof(OllamaFunctionToolCall))] -[JsonSerializable(typeof(OllamaFunctionToolParameter))] -[JsonSerializable(typeof(OllamaFunctionToolParameters))] -[JsonSerializable(typeof(OllamaRequestOptions))] -[JsonSerializable(typeof(OllamaTool))] -[JsonSerializable(typeof(OllamaToolCall))] -[JsonSerializable(typeof(OllamaEmbeddingRequest))] -[JsonSerializable(typeof(OllamaEmbeddingResponse))] -internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj deleted file mode 100644 index 96734131c83..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - Microsoft.Extensions.AI - Implementation of generative AI abstractions for Ollama. This package is deprecated, and the OllamaSharp package is recommended. - AI - - - - preview - false - 78 - 0 - - - - $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA2227;SA1316;S1121;EA0002 - true - true - - - - true - true - true - true - true - - - - - - - - - - - - - - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.json b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.json deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs deleted file mode 100644 index 42f75af495e..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ /dev/null @@ -1,505 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S3358 // Ternary operators should not be nested - -namespace Microsoft.Extensions.AI; - -/// Represents an for Ollama. -public sealed class OllamaChatClient : IChatClient -{ - private static readonly JsonElement _schemalessJsonResponseFormatValue = JsonDocument.Parse("\"json\"").RootElement; - - private static readonly AIJsonSchemaTransformCache _schemaTransformCache = new(new() - { - ConvertBooleanSchemas = true, - }); - - /// Metadata about the client. - private readonly ChatClientMetadata _metadata; - - /// The api/chat endpoint URI. - private readonly Uri _apiChatEndpoint; - - /// The to use for sending requests. - private readonly HttpClient _httpClient; - - /// The use for any serialization activities related to tool call arguments and results. - private JsonSerializerOptions _toolCallJsonSerializerOptions = AIJsonUtilities.DefaultOptions; - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - public OllamaChatClient(string endpoint, string? modelId = null, HttpClient? httpClient = null) - : this(new Uri(Throw.IfNull(endpoint)), modelId, httpClient) - { - } - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - /// is . - /// is empty or composed entirely of whitespace. - public OllamaChatClient(Uri endpoint, string? modelId = null, HttpClient? httpClient = null) - { - _ = Throw.IfNull(endpoint); - if (modelId is not null) - { - _ = Throw.IfNullOrWhitespace(modelId); - } - - _apiChatEndpoint = new Uri(endpoint, "api/chat"); - _httpClient = httpClient ?? OllamaUtilities.SharedClient; - - _metadata = new ChatClientMetadata("ollama", endpoint, modelId); - } - - /// Gets or sets to use for any serialization activities related to tool call arguments and results. - public JsonSerializerOptions ToolCallJsonSerializerOptions - { - get => _toolCallJsonSerializerOptions; - set => _toolCallJsonSerializerOptions = Throw.IfNull(value); - } - - /// - public async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - using var httpResponse = await _httpClient.PostAsJsonAsync( - _apiChatEndpoint, - ToOllamaChatRequest(messages, options, stream: false), - JsonContext.Default.OllamaChatRequest, - cancellationToken).ConfigureAwait(false); - - if (!httpResponse.IsSuccessStatusCode) - { - await OllamaUtilities.ThrowUnsuccessfulOllamaResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - } - - var response = (await httpResponse.Content.ReadFromJsonAsync( - JsonContext.Default.OllamaChatResponse, - cancellationToken).ConfigureAwait(false))!; - - if (!string.IsNullOrEmpty(response.Error)) - { - throw new InvalidOperationException($"Ollama error: {response.Error}"); - } - - var responseId = Guid.NewGuid().ToString("N"); - - return new(FromOllamaMessage(response.Message!, responseId)) - { - CreatedAt = DateTimeOffset.TryParse(response.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, - FinishReason = ToFinishReason(response), - ModelId = response.Model ?? options?.ModelId ?? _metadata.DefaultModelId, - ResponseId = responseId, - Usage = ParseOllamaChatResponseUsage(response), - }; - } - - /// - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - using HttpRequestMessage request = new(HttpMethod.Post, _apiChatEndpoint) - { - Content = JsonContent.Create(ToOllamaChatRequest(messages, options, stream: true), JsonContext.Default.OllamaChatRequest) - }; - using var httpResponse = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - - if (!httpResponse.IsSuccessStatusCode) - { - await OllamaUtilities.ThrowUnsuccessfulOllamaResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - } - - // Ollama doesn't set a response ID on streamed chunks, so we need to generate one. - var responseId = Guid.NewGuid().ToString("N"); - - using var httpResponseStream = await httpResponse.Content -#if NET - .ReadAsStreamAsync(cancellationToken) -#else - .ReadAsStreamAsync() -#endif - .ConfigureAwait(false); - - using var streamReader = new StreamReader(httpResponseStream); -#if NET - while ((await streamReader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is { } line) -#else - while ((await streamReader.ReadLineAsync().ConfigureAwait(false)) is { } line) -#endif - { - var chunk = JsonSerializer.Deserialize(line, JsonContext.Default.OllamaChatResponse); - if (chunk is null) - { - continue; - } - - string? modelId = chunk.Model ?? _metadata.DefaultModelId; - - ChatResponseUpdate update = new() - { - CreatedAt = DateTimeOffset.TryParse(chunk.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, - FinishReason = ToFinishReason(chunk), - ModelId = modelId, - ResponseId = responseId, - MessageId = responseId, // There is no per-message ID, but there's only one message per response, so use the response ID - Role = chunk.Message?.Role is not null ? new ChatRole(chunk.Message.Role) : null, - }; - - if (chunk.Message is { } message) - { - if (message.ToolCalls is { Length: > 0 }) - { - foreach (var toolCall in message.ToolCalls) - { - if (toolCall.Function is { } function) - { - update.Contents.Add(ToFunctionCallContent(function)); - } - } - } - - // Equivalent rule to the nonstreaming case - if (message.Content?.Length > 0 || update.Contents.Count == 0) - { - update.Contents.Insert(0, new TextContent(message.Content)); - } - } - - if (ParseOllamaChatResponseUsage(chunk) is { } usage) - { - update.Contents.Add(new UsageContent(usage)); - } - - yield return update; - } - } - - /// - object? IChatClient.GetService(Type serviceType, object? serviceKey) - { - _ = Throw.IfNull(serviceType); - - return - serviceKey is not null ? null : - serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType.IsInstanceOfType(this) ? this : - null; - } - - /// - public void Dispose() - { - if (_httpClient != OllamaUtilities.SharedClient) - { - _httpClient.Dispose(); - } - } - - private static UsageDetails? ParseOllamaChatResponseUsage(OllamaChatResponse response) - { - AdditionalPropertiesDictionary? additionalCounts = null; - OllamaUtilities.TransferNanosecondsTime(response, static r => r.LoadDuration, "load_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.TotalDuration, "total_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.PromptEvalDuration, "prompt_eval_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.EvalDuration, "eval_duration", ref additionalCounts); - - if (additionalCounts is not null || response.PromptEvalCount is not null || response.EvalCount is not null) - { - return new() - { - InputTokenCount = response.PromptEvalCount, - OutputTokenCount = response.EvalCount, - TotalTokenCount = response.PromptEvalCount.GetValueOrDefault() + response.EvalCount.GetValueOrDefault(), - AdditionalCounts = additionalCounts, - }; - } - - return null; - } - - private static ChatFinishReason? ToFinishReason(OllamaChatResponse response) => - response.DoneReason switch - { - null => null, - "length" => ChatFinishReason.Length, - "stop" => ChatFinishReason.Stop, - _ => new ChatFinishReason(response.DoneReason), - }; - - private static ChatMessage FromOllamaMessage(OllamaChatResponseMessage message, string responseId) - { - List contents = []; - - // Add any tool calls. - if (message.ToolCalls is { Length: > 0 }) - { - foreach (var toolCall in message.ToolCalls) - { - if (toolCall.Function is { } function) - { - contents.Add(ToFunctionCallContent(function)); - } - } - } - - // Ollama frequently sends back empty content with tool calls. Rather than always adding an empty - // content, we only add the content if either it's not empty or there weren't any tool calls. - if (message.Content?.Length > 0 || contents.Count == 0) - { - contents.Insert(0, new TextContent(message.Content)); - } - - // Ollama doesn't have per-message IDs, so use the response ID in the same way we do when streaming - return new ChatMessage(new(message.Role), contents) { MessageId = responseId }; - } - - private static FunctionCallContent ToFunctionCallContent(OllamaFunctionToolCall function) - { -#if NET - var id = System.Security.Cryptography.RandomNumberGenerator.GetHexString(8); -#else - var id = Guid.NewGuid().ToString().Substring(0, 8); -#endif - return new FunctionCallContent(id, function.Name, function.Arguments); - } - - private static JsonElement? ToOllamaChatResponseFormat(ChatResponseFormat? format) - { - if (format is ChatResponseFormatJson jsonFormat) - { - return _schemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) ?? _schemalessJsonResponseFormatValue; - } - else - { - return null; - } - } - - private OllamaChatRequest ToOllamaChatRequest(IEnumerable messages, ChatOptions? options, bool stream) - { - var requestMessages = messages.SelectMany(ToOllamaChatRequestMessages).ToList(); - if (options?.Instructions is string instructions) - { - requestMessages.Insert(0, new OllamaChatRequestMessage - { - Role = ChatRole.System.Value, - Content = instructions, - }); - } - - OllamaChatRequest request = new() - { - Format = ToOllamaChatResponseFormat(options?.ResponseFormat), - Messages = requestMessages, - Model = options?.ModelId ?? _metadata.DefaultModelId ?? string.Empty, - Stream = stream, - Tools = options?.ToolMode is not NoneChatToolMode && options?.Tools is { Count: > 0 } tools ? tools.OfType().Select(ToOllamaTool) : null, - }; - - if (options is not null) - { - TransferMetadataValue(nameof(OllamaRequestOptions.embedding_only), (options, value) => options.embedding_only = value); - TransferMetadataValue(nameof(OllamaRequestOptions.f16_kv), (options, value) => options.f16_kv = value); - TransferMetadataValue(nameof(OllamaRequestOptions.logits_all), (options, value) => options.logits_all = value); - TransferMetadataValue(nameof(OllamaRequestOptions.low_vram), (options, value) => options.low_vram = value); - TransferMetadataValue(nameof(OllamaRequestOptions.main_gpu), (options, value) => options.main_gpu = value); - TransferMetadataValue(nameof(OllamaRequestOptions.min_p), (options, value) => options.min_p = value); - TransferMetadataValue(nameof(OllamaRequestOptions.mirostat), (options, value) => options.mirostat = value); - TransferMetadataValue(nameof(OllamaRequestOptions.mirostat_eta), (options, value) => options.mirostat_eta = value); - TransferMetadataValue(nameof(OllamaRequestOptions.mirostat_tau), (options, value) => options.mirostat_tau = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_batch), (options, value) => options.num_batch = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_ctx), (options, value) => options.num_ctx = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_gpu), (options, value) => options.num_gpu = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_keep), (options, value) => options.num_keep = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_thread), (options, value) => options.num_thread = value); - TransferMetadataValue(nameof(OllamaRequestOptions.numa), (options, value) => options.numa = value); - TransferMetadataValue(nameof(OllamaRequestOptions.penalize_newline), (options, value) => options.penalize_newline = value); - TransferMetadataValue(nameof(OllamaRequestOptions.repeat_last_n), (options, value) => options.repeat_last_n = value); - TransferMetadataValue(nameof(OllamaRequestOptions.repeat_penalty), (options, value) => options.repeat_penalty = value); - TransferMetadataValue(nameof(OllamaRequestOptions.tfs_z), (options, value) => options.tfs_z = value); - TransferMetadataValue(nameof(OllamaRequestOptions.typical_p), (options, value) => options.typical_p = value); - TransferMetadataValue(nameof(OllamaRequestOptions.use_mmap), (options, value) => options.use_mmap = value); - TransferMetadataValue(nameof(OllamaRequestOptions.use_mlock), (options, value) => options.use_mlock = value); - TransferMetadataValue(nameof(OllamaRequestOptions.vocab_only), (options, value) => options.vocab_only = value); - - if (options.FrequencyPenalty is float frequencyPenalty) - { - (request.Options ??= new()).frequency_penalty = frequencyPenalty; - } - - if (options.MaxOutputTokens is int maxOutputTokens) - { - (request.Options ??= new()).num_predict = maxOutputTokens; - } - - if (options.PresencePenalty is float presencePenalty) - { - (request.Options ??= new()).presence_penalty = presencePenalty; - } - - if (options.StopSequences is { Count: > 0 }) - { - (request.Options ??= new()).stop = [.. options.StopSequences]; - } - - if (options.Temperature is float temperature) - { - (request.Options ??= new()).temperature = temperature; - } - - if (options.TopP is float topP) - { - (request.Options ??= new()).top_p = topP; - } - - if (options.TopK is int topK) - { - (request.Options ??= new()).top_k = topK; - } - - if (options.Seed is long seed) - { - (request.Options ??= new()).seed = seed; - } - } - - return request; - - void TransferMetadataValue(string propertyName, Action setOption) - { - if (options.AdditionalProperties?.TryGetValue(propertyName, out T? t) is true) - { - request.Options ??= new(); - setOption(request.Options, t); - } - } - } - - private IEnumerable ToOllamaChatRequestMessages(ChatMessage content) - { - // In general, we return a single request message for each understood content item. - // However, various image models expect both text and images in the same request message. - // To handle that, attach images to a previous text message if one exists. - - OllamaChatRequestMessage? currentTextMessage = null; - foreach (var item in content.Contents) - { - if (item is DataContent dataContent && dataContent.HasTopLevelMediaType("image")) - { - IList images = currentTextMessage?.Images ?? []; - images.Add(dataContent.Base64Data.ToString()); - - if (currentTextMessage is not null) - { - currentTextMessage.Images = images; - } - else - { - yield return new OllamaChatRequestMessage - { - Role = content.Role.Value, - Images = images, - }; - } - } - else - { - if (currentTextMessage is not null) - { - yield return currentTextMessage; - currentTextMessage = null; - } - - switch (item) - { - case TextContent textContent: - currentTextMessage = new OllamaChatRequestMessage - { - Role = content.Role.Value, - Content = textContent.Text, - }; - break; - - case FunctionCallContent fcc: - { - yield return new OllamaChatRequestMessage - { - Role = "assistant", - Content = JsonSerializer.Serialize(new OllamaFunctionCallContent - { - CallId = fcc.CallId, - Name = fcc.Name, - Arguments = JsonSerializer.SerializeToElement(fcc.Arguments, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(IDictionary))), - }, JsonContext.Default.OllamaFunctionCallContent) - }; - break; - } - - case FunctionResultContent frc: - { - JsonElement jsonResult = JsonSerializer.SerializeToElement(frc.Result, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); - yield return new OllamaChatRequestMessage - { - Role = "tool", - Content = JsonSerializer.Serialize(new OllamaFunctionResultContent - { - CallId = frc.CallId, - Result = jsonResult, - }, JsonContext.Default.OllamaFunctionResultContent) - }; - break; - } - } - } - } - - if (currentTextMessage is not null) - { - yield return currentTextMessage; - } - } - - private static OllamaTool ToOllamaTool(AIFunction function) - { - return new() - { - Type = "function", - Function = new OllamaFunctionTool - { - Name = function.Name, - Description = function.Description, - Parameters = JsonSerializer.Deserialize(_schemaTransformCache.GetOrCreateTransformedSchema(function), JsonContext.Default.OllamaFunctionToolParameters)!, - } - }; - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs deleted file mode 100644 index 7cdadb91666..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs +++ /dev/null @@ -1,17 +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.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatRequest -{ - public required string Model { get; set; } - public required IList Messages { get; set; } - public JsonElement? Format { get; set; } - public bool Stream { get; set; } - public IEnumerable? Tools { get; set; } - public OllamaRequestOptions? Options { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs deleted file mode 100644 index 5a377b1eb34..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs +++ /dev/null @@ -1,13 +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; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatRequestMessage -{ - public required string Role { get; set; } - public string? Content { get; set; } - public IList? Images { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs deleted file mode 100644 index 8c39f9ab598..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatResponse -{ - public string? Model { get; set; } - public string? CreatedAt { get; set; } - public long? TotalDuration { get; set; } - public long? LoadDuration { get; set; } - public string? DoneReason { get; set; } - public int? PromptEvalCount { get; set; } - public long? PromptEvalDuration { get; set; } - public int? EvalCount { get; set; } - public long? EvalDuration { get; set; } - public OllamaChatResponseMessage? Message { get; set; } - public bool Done { get; set; } - public string? Error { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs deleted file mode 100644 index bf73c08d793..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatResponseMessage -{ - public required string Role { get; set; } - public required string Content { get; set; } - public OllamaToolCall[]? ToolCalls { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs deleted file mode 100644 index 0b0d4d3b344..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs +++ /dev/null @@ -1,165 +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 System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable S3358 // Ternary operators should not be nested - -namespace Microsoft.Extensions.AI; - -/// Represents an for Ollama. -public sealed class OllamaEmbeddingGenerator : IEmbeddingGenerator> -{ - /// Metadata about the embedding generator. - private readonly EmbeddingGeneratorMetadata _metadata; - - /// The api/embeddings endpoint URI. - private readonly Uri _apiEmbeddingsEndpoint; - - /// The to use for sending requests. - private readonly HttpClient _httpClient; - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - public OllamaEmbeddingGenerator(string endpoint, string? modelId = null, HttpClient? httpClient = null) - : this(new Uri(Throw.IfNull(endpoint)), modelId, httpClient) - { - } - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - /// is . - /// is empty or composed entirely of whitespace. - public OllamaEmbeddingGenerator(Uri endpoint, string? modelId = null, HttpClient? httpClient = null) - { - _ = Throw.IfNull(endpoint); - if (modelId is not null) - { - _ = Throw.IfNullOrWhitespace(modelId); - } - - _apiEmbeddingsEndpoint = new Uri(endpoint, "api/embed"); - _httpClient = httpClient ?? OllamaUtilities.SharedClient; - _metadata = new("ollama", endpoint, modelId); - } - - /// - object? IEmbeddingGenerator.GetService(Type serviceType, object? serviceKey) - { - _ = Throw.IfNull(serviceType); - - return - serviceKey is not null ? null : - serviceType == typeof(EmbeddingGeneratorMetadata) ? _metadata : - serviceType.IsInstanceOfType(this) ? this : - null; - } - - /// - public void Dispose() - { - if (_httpClient != OllamaUtilities.SharedClient) - { - _httpClient.Dispose(); - } - } - - /// - public async Task>> GenerateAsync( - IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(values); - - // Create request. - string[] inputs = values.ToArray(); - string? requestModel = options?.ModelId ?? _metadata.DefaultModelId; - var request = new OllamaEmbeddingRequest - { - Model = requestModel ?? string.Empty, - Input = inputs, - }; - - if (options?.AdditionalProperties is { } requestProps) - { - if (requestProps.TryGetValue("keep_alive", out long keepAlive)) - { - request.KeepAlive = keepAlive; - } - - if (requestProps.TryGetValue("truncate", out bool truncate)) - { - request.Truncate = truncate; - } - } - - // Send request and get response. - var httpResponse = await _httpClient.PostAsJsonAsync( - _apiEmbeddingsEndpoint, - request, - JsonContext.Default.OllamaEmbeddingRequest, - cancellationToken).ConfigureAwait(false); - - if (!httpResponse.IsSuccessStatusCode) - { - await OllamaUtilities.ThrowUnsuccessfulOllamaResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - } - - var response = (await httpResponse.Content.ReadFromJsonAsync( - JsonContext.Default.OllamaEmbeddingResponse, - cancellationToken).ConfigureAwait(false))!; - - // Validate response. - if (!string.IsNullOrEmpty(response.Error)) - { - throw new InvalidOperationException($"Ollama error: {response.Error}"); - } - - if (response.Embeddings is null || response.Embeddings.Length != inputs.Length) - { - throw new InvalidOperationException($"Ollama generated {response.Embeddings?.Length ?? 0} embeddings but {inputs.Length} were expected."); - } - - // Convert response into result objects. - AdditionalPropertiesDictionary? additionalCounts = null; - OllamaUtilities.TransferNanosecondsTime(response, r => r.TotalDuration, "total_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, r => r.LoadDuration, "load_duration", ref additionalCounts); - - UsageDetails? usage = null; - if (additionalCounts is not null || response.PromptEvalCount is not null) - { - usage = new() - { - InputTokenCount = response.PromptEvalCount, - TotalTokenCount = response.PromptEvalCount, - AdditionalCounts = additionalCounts, - }; - } - - return new(response.Embeddings.Select(e => - new Embedding(e) - { - CreatedAt = DateTimeOffset.UtcNow, - ModelId = response.Model ?? requestModel, - })) - { - Usage = usage, - }; - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs deleted file mode 100644 index 07e3530b8ed..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaEmbeddingRequest -{ - public required string Model { get; set; } - public required string[] Input { get; set; } - public OllamaRequestOptions? Options { get; set; } - public bool? Truncate { get; set; } - public long? KeepAlive { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs deleted file mode 100644 index c4fd2cde87c..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaEmbeddingResponse -{ - [JsonPropertyName("model")] - public string? Model { get; set; } - [JsonPropertyName("embeddings")] - public float[][]? Embeddings { get; set; } - [JsonPropertyName("total_duration")] - public long? TotalDuration { get; set; } - [JsonPropertyName("load_duration")] - public long? LoadDuration { get; set; } - [JsonPropertyName("prompt_eval_count")] - public int? PromptEvalCount { get; set; } - public string? Error { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs deleted file mode 100644 index f518413586a..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionCallContent -{ - public string? CallId { get; set; } - public string? Name { get; set; } - public JsonElement Arguments { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs deleted file mode 100644 index ba3eab607b8..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionResultContent -{ - public string? CallId { get; set; } - public JsonElement Result { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs deleted file mode 100644 index 880e37bec2a..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionTool -{ - public required string Name { get; set; } - public required string Description { get; set; } - public required OllamaFunctionToolParameters Parameters { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs deleted file mode 100644 index c94d41bd3f3..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs +++ /dev/null @@ -1,12 +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; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionToolCall -{ - public required string Name { get; set; } - public IDictionary? Arguments { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs deleted file mode 100644 index 77ba2a5561c..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs +++ /dev/null @@ -1,13 +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; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionToolParameter -{ - public string? Type { get; set; } - public string? Description { get; set; } - public IEnumerable? Enum { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs deleted file mode 100644 index 9fa7d0d2adc..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs +++ /dev/null @@ -1,14 +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.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionToolParameters -{ - public string Type { get; set; } = "object"; - public required IDictionary Properties { get; set; } - public IList? Required { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs deleted file mode 100644 index cc8b548c1a1..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -#pragma warning disable IDE1006 // Naming Styles - -internal sealed class OllamaRequestOptions -{ - public bool? embedding_only { get; set; } - public bool? f16_kv { get; set; } - public float? frequency_penalty { get; set; } - public bool? logits_all { get; set; } - public bool? low_vram { get; set; } - public int? main_gpu { get; set; } - public float? min_p { get; set; } - public int? mirostat { get; set; } - public float? mirostat_eta { get; set; } - public float? mirostat_tau { get; set; } - public int? num_batch { get; set; } - public int? num_ctx { get; set; } - public int? num_gpu { get; set; } - public int? num_keep { get; set; } - public int? num_predict { get; set; } - public int? num_thread { get; set; } - public bool? numa { get; set; } - public bool? penalize_newline { get; set; } - public float? presence_penalty { get; set; } - public int? repeat_last_n { get; set; } - public float? repeat_penalty { get; set; } - public long? seed { get; set; } - public string[]? stop { get; set; } - public float? temperature { get; set; } - public float? tfs_z { get; set; } - public int? top_k { get; set; } - public float? top_p { get; set; } - public float? typical_p { get; set; } - public bool? use_mlock { get; set; } - public bool? use_mmap { get; set; } - public bool? vocab_only { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.cs deleted file mode 100644 index 457793dc476..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaTool -{ - public required string Type { get; set; } - public required OllamaFunctionTool Function { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs deleted file mode 100644 index a00d0e0e290..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaToolCall -{ - public OllamaFunctionToolCall? Function { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs deleted file mode 100644 index ea2625bd50e..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.AI; - -internal static class OllamaUtilities -{ - /// Gets a singleton used when no other instance is supplied. - public static HttpClient SharedClient { get; } = new() - { - // Expected use is localhost access for non-production use. Typical production use should supply - // an HttpClient configured with whatever more robust resilience policy / handlers are appropriate. - Timeout = Timeout.InfiniteTimeSpan, - }; - - public static void TransferNanosecondsTime(TResponse response, Func getNanoseconds, string key, ref AdditionalPropertiesDictionary? metadata) - { - if (getNanoseconds(response) is long duration) - { - try - { - (metadata ??= [])[key] = duration; - } - catch (OverflowException) - { - // Ignore options that don't convert - } - } - } - - [DoesNotReturn] - public static async ValueTask ThrowUnsuccessfulOllamaResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) - { - Debug.Assert(!response.IsSuccessStatusCode, "must only be invoked for unsuccessful responses."); - - // Read the entire response content into a string. - string errorContent = -#if NET - await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); -#else - await response.Content.ReadAsStringAsync().ConfigureAwait(false); -#endif - - // The response content *could* be JSON formatted, try to extract the error field. - -#pragma warning disable CA1031 // Do not catch general exception types - try - { - using JsonDocument document = JsonDocument.Parse(errorContent); - if (document.RootElement.TryGetProperty("error", out JsonElement errorElement) && - errorElement.ValueKind is JsonValueKind.String) - { - errorContent = errorElement.GetString()!; - } - } - catch - { - // Ignore JSON parsing errors. - } -#pragma warning restore CA1031 // Do not catch general exception types - - throw new InvalidOperationException($"Ollama error: {errorContent}"); - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md deleted file mode 100644 index 3c85cd17087..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Microsoft.Extensions.AI.Ollama - -This package is deprecated and the [OllamaSharp](https://www.nuget.org/packages/ollamasharp) package is recommended. `OllamaSharp` provides .NET bindings for the [Ollama API](https://github.com/jmorganca/ollama/blob/main/docs/api.md), simplifying interactions with Ollama both locally and remotely. - -No further updates, features, or fixes are planned for the `Microsoft.Extensions.AI.Ollama` package. - -## Related packages - -* [OllamaSharp](https://www.nuget.org/packages/OllamaSharp) -* [Microsoft.Extensions.AI](https://www.nuget.org/packages/Microsoft.Extensions.AI) -* [Microsoft.Extensions.AI.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.AI.Abstractions) diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj deleted file mode 100644 index 5db789e3b6b..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - Microsoft.Extensions.AI - Unit tests for Microsoft.Extensions.AI.Ollama - - - - true - - - - - - - - - - - - - - diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs deleted file mode 100644 index 83e84e49f5b..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs +++ /dev/null @@ -1,115 +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 System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.TestUtilities; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OllamaChatClientIntegrationTests : ChatClientIntegrationTests -{ - protected override IChatClient? CreateChatClient() => - IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? - new OllamaChatClient(endpoint, "llama3.1") : - null; - - public override Task FunctionInvocation_RequireAny() => - throw new SkipTestException("Ollama does not currently support requiring function invocation."); - - public override Task FunctionInvocation_RequireSpecific() => - throw new SkipTestException("Ollama does not currently support requiring function invocation."); - - protected override string? GetModel_MultiModal_DescribeImage() => "llava"; - - [ConditionalFact] - public async Task PromptBasedFunctionCalling_NoArgs() - { - SkipIfNotEnabled(); - - using var chatClient = CreateChatClient()! - .AsBuilder() - .UseFunctionInvocation() - .UsePromptBasedFunctionCalling() - .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) - .Build(); - - var secretNumber = 42; - var response = await chatClient.GetResponseAsync("What is the current secret number? Answer with digits only.", new ChatOptions - { - ModelId = "llama3:8b", - Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")], - Temperature = 0, - Seed = 0, - }); - - Assert.Contains(secretNumber.ToString(), response.Text); - } - - [ConditionalFact] - public async Task PromptBasedFunctionCalling_WithArgs() - { - SkipIfNotEnabled(); - - using var chatClient = CreateChatClient()! - .AsBuilder() - .UseFunctionInvocation() - .UsePromptBasedFunctionCalling() - .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) - .Build(); - - var stockPriceTool = AIFunctionFactory.Create([Description("Returns the stock price for a given ticker symbol")] ( - [Description("The ticker symbol")] string symbol, - [Description("The currency code such as USD or JPY")] string currency) => - { - Assert.Equal("MSFT", symbol); - Assert.Equal("GBP", currency); - return 999; - }, "GetStockPrice"); - - var didCallIrrelevantTool = false; - var irrelevantTool = AIFunctionFactory.Create(() => { didCallIrrelevantTool = true; return 123; }, "GetSecretNumber"); - - var response = await chatClient.GetResponseAsync("What's the stock price for Microsoft in British pounds?", new ChatOptions - { - Tools = [stockPriceTool, irrelevantTool], - Temperature = 0, - Seed = 0, - }); - - Assert.Contains("999", response.Text); - Assert.False(didCallIrrelevantTool); - } - - [ConditionalFact] - public async Task InvalidModelParameter_ThrowsInvalidOperationException() - { - SkipIfNotEnabled(); - - var endpoint = IntegrationTestHelpers.GetOllamaUri(); - Assert.NotNull(endpoint); - - using var chatClient = new OllamaChatClient(endpoint, modelId: "inexistent-model"); - - InvalidOperationException ex; - ex = await Assert.ThrowsAsync(() => chatClient.GetResponseAsync("Hello, world!")); - Assert.Contains("inexistent-model", ex.Message); - - ex = await Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("Hello, world!").ToChatResponseAsync()); - Assert.Contains("inexistent-model", ex.Message); - } - - private sealed class AssertNoToolsDefinedChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) - { - public override Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - Assert.Null(options?.Tools); - return base.GetResponseAsync(messages, options, cancellationToken); - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs deleted file mode 100644 index 2f716d2fe7d..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ /dev/null @@ -1,487 +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 System.ComponentModel; -using System.Linq; -using System.Net.Http; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Xunit; - -#pragma warning disable S103 // Lines should not be too long - -namespace Microsoft.Extensions.AI; - -public class OllamaChatClientTests -{ - [Fact] - public void Ctor_InvalidArgs_Throws() - { - Assert.Throws("endpoint", () => new OllamaChatClient((Uri)null!)); - Assert.Throws("modelId", () => new OllamaChatClient("http://localhost", " ")); - } - - [Fact] - public void ToolCallJsonSerializerOptions_HasExpectedValue() - { - using OllamaChatClient client = new("http://localhost", "model"); - - Assert.Same(client.ToolCallJsonSerializerOptions, AIJsonUtilities.DefaultOptions); - Assert.Throws("value", () => client.ToolCallJsonSerializerOptions = null!); - - JsonSerializerOptions options = new(); - client.ToolCallJsonSerializerOptions = options; - Assert.Same(options, client.ToolCallJsonSerializerOptions); - } - - [Fact] - public void GetService_SuccessfullyReturnsUnderlyingClient() - { - using OllamaChatClient client = new("http://localhost"); - - Assert.Same(client, client.GetService()); - Assert.Same(client, client.GetService()); - - using IChatClient pipeline = client - .AsBuilder() - .UseFunctionInvocation() - .UseOpenTelemetry() - .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Build(); - - Assert.NotNull(pipeline.GetService()); - Assert.NotNull(pipeline.GetService()); - Assert.NotNull(pipeline.GetService()); - Assert.NotNull(pipeline.GetService()); - - Assert.Same(client, pipeline.GetService()); - Assert.IsType(pipeline.GetService()); - } - - [Fact] - public void Ctor_ProducesExpectedMetadata() - { - Uri endpoint = new("http://localhost/some/endpoint"); - string model = "amazingModel"; - - using IChatClient chatClient = new OllamaChatClient(endpoint, model); - var metadata = chatClient.GetService(); - Assert.NotNull(metadata); - Assert.Equal("ollama", metadata.ProviderName); - Assert.Equal(endpoint, metadata.ProviderUri); - Assert.Equal(model, metadata.DefaultModelId); - } - - [Fact] - public async Task BasicRequestResponse_NonStreaming() - { - const string Input = """ - { - "model":"llama3.1", - "messages":[{"role":"user","content":"hello"}], - "stream":false, - "options":{"num_predict":10,"temperature":0.5} - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T15:46:10.5248793Z", - "message": { - "role": "assistant", - "content": "Hello! How are you today? Is there something" - }, - "done_reason": "length", - "done": true, - "total_duration": 22186844400, - "load_duration": 17947219100, - "prompt_eval_count": 11, - "prompt_eval_duration": 1953805000, - "eval_count": 10, - "eval_duration": 2277274000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using OllamaChatClient client = new("http://localhost:11434", "llama3.1", httpClient); - var response = await client.GetResponseAsync("hello", new() - { - MaxOutputTokens = 10, - Temperature = 0.5f, - }); - Assert.NotNull(response); - - Assert.Equal("Hello! How are you today? Is there something", response.Text); - Assert.Single(response.Messages.Single().Contents); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T15:46:10.5248793Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Length, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(11, response.Usage.InputTokenCount); - Assert.Equal(10, response.Usage.OutputTokenCount); - Assert.Equal(21, response.Usage.TotalTokenCount); - } - - [Fact] - public async Task BasicRequestResponse_Streaming() - { - const string Input = """ - { - "model":"llama3.1", - "messages":[{"role":"user","content":"hello"}], - "stream":true, - "options":{"num_predict":20,"temperature":0.5} - } - """; - - const string Output = """ - {"model":"llama3.1","created_at":"2024-10-01T16:15:20.4965315Z","message":{"role":"assistant","content":"Hello"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:20.763058Z","message":{"role":"assistant","content":"!"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:20.9751134Z","message":{"role":"assistant","content":" How"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.1788125Z","message":{"role":"assistant","content":" are"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.3883171Z","message":{"role":"assistant","content":" you"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.5912498Z","message":{"role":"assistant","content":" today"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.7968039Z","message":{"role":"assistant","content":"?"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.0034152Z","message":{"role":"assistant","content":" Is"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.1931196Z","message":{"role":"assistant","content":" there"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.3827484Z","message":{"role":"assistant","content":" something"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.5659027Z","message":{"role":"assistant","content":" I"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.7488871Z","message":{"role":"assistant","content":" can"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.9339881Z","message":{"role":"assistant","content":" help"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.1201564Z","message":{"role":"assistant","content":" you"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.303447Z","message":{"role":"assistant","content":" with"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.4964909Z","message":{"role":"assistant","content":" or"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.6837816Z","message":{"role":"assistant","content":" would"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.8723142Z","message":{"role":"assistant","content":" you"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:24.064613Z","message":{"role":"assistant","content":" like"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:24.2504498Z","message":{"role":"assistant","content":" to"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:24.2514508Z","message":{"role":"assistant","content":""},"done_reason":"length", "done":true,"total_duration":11912402900,"load_duration":6824559200,"prompt_eval_count":11,"prompt_eval_duration":1329601000,"eval_count":20,"eval_duration":3754262000} - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient); - - List updates = []; - var streamingResponse = client.GetStreamingResponseAsync("hello", new() - { - MaxOutputTokens = 20, - Temperature = 0.5f, - }); - await foreach (var update in streamingResponse) - { - updates.Add(update); - } - - Assert.Equal(21, updates.Count); - - DateTimeOffset[] createdAts = Regex.Matches(Output, @"2024.*?Z").Cast().Select(m => DateTimeOffset.Parse(m.Value)).ToArray(); - - for (int i = 0; i < updates.Count; i++) - { - Assert.NotNull(updates[i].ResponseId); - Assert.NotNull(updates[i].MessageId); - Assert.Equal(i < updates.Count - 1 ? 1 : 2, updates[i].Contents.Count); - Assert.Equal(ChatRole.Assistant, updates[i].Role); - Assert.Equal("llama3.1", updates[i].ModelId); - Assert.Equal(createdAts[i], updates[i].CreatedAt); - Assert.Equal(i < updates.Count - 1 ? null : ChatFinishReason.Length, updates[i].FinishReason); - } - - Assert.Equal("Hello! How are you today? Is there something I can help you with or would you like to", string.Concat(updates.Select(u => u.Text))); - Assert.Equal(2, updates[updates.Count - 1].Contents.Count); - Assert.IsType(updates[updates.Count - 1].Contents[0]); - UsageContent usage = Assert.IsType(updates[updates.Count - 1].Contents[1]); - Assert.Equal(11, usage.Details.InputTokenCount); - Assert.Equal(20, usage.Details.OutputTokenCount); - Assert.Equal(31, usage.Details.TotalTokenCount); - - var chatResponse = await streamingResponse.ToChatResponseAsync(); - Assert.Single(Assert.Single(chatResponse.Messages).Contents); - Assert.Equal("Hello! How are you today? Is there something I can help you with or would you like to", chatResponse.Text); - } - - [Fact] - public async Task MultipleMessages_NonStreaming() - { - const string Input = """ - { - "model": "llama3.1", - "messages": [ - { - "role": "user", - "content": "hello!" - }, - { - "role": "assistant", - "content": "hi, how are you?" - }, - { - "role": "user", - "content": "i\u0027m good. how are you?" - } - ], - "stream": false, - "options": { - "frequency_penalty": 0.75, - "presence_penalty": 0.5, - "seed": 42, - "stop": ["great"], - "temperature": 0.25 - } - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T17:18:46.308987Z", - "message": { - "role": "assistant", - "content": "I'm just a computer program, so I don't have feelings or emotions like humans do, but I'm functioning properly and ready to help with any questions or tasks you may have! How about we chat about something in particular or just shoot the breeze? Your choice!" - }, - "done_reason": "stop", - "done": true, - "total_duration": 23229369000, - "load_duration": 7724086300, - "prompt_eval_count": 36, - "prompt_eval_duration": 4245660000, - "eval_count": 55, - "eval_duration": 11256470000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = new OllamaChatClient("http://localhost:11434", httpClient: httpClient); - - List messages = - [ - new(ChatRole.User, "hello!"), - new(ChatRole.Assistant, "hi, how are you?"), - new(ChatRole.User, "i'm good. how are you?"), - ]; - - var response = await client.GetResponseAsync(messages, new() - { - ModelId = "llama3.1", - Temperature = 0.25f, - FrequencyPenalty = 0.75f, - PresencePenalty = 0.5f, - StopSequences = ["great"], - Seed = 42, - }); - Assert.NotNull(response); - - Assert.Equal( - VerbatimHttpHandler.RemoveWhiteSpace(""" - I'm just a computer program, so I don't have feelings or emotions like humans do, - but I'm functioning properly and ready to help with any questions or tasks you may have! - How about we chat about something in particular or just shoot the breeze ? Your choice! - """), - VerbatimHttpHandler.RemoveWhiteSpace(response.Text)); - Assert.Single(response.Messages.Single().Contents); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T17:18:46.308987Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Stop, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(36, response.Usage.InputTokenCount); - Assert.Equal(55, response.Usage.OutputTokenCount); - Assert.Equal(91, response.Usage.TotalTokenCount); - } - - [Fact] - public async Task FunctionCallContent_NonStreaming() - { - const string Input = """ - { - "model": "llama3.1", - "messages": [ - { - "role": "user", - "content": "How old is Alice?" - } - ], - "stream": false, - "tools": [ - { - "type": "function", - "function": { - "name": "GetPersonAge", - "description": "Gets the age of the specified person.", - "parameters": { - "type": "object", - "properties": { - "personName": { - "description": "The person whose age is being requested", - "type": "string" - } - }, - "required": ["personName"] - } - } - } - ] - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T18:48:30.2669578Z", - "message": { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "function": { - "name": "GetPersonAge", - "arguments": { - "personName": "Alice" - } - } - } - ] - }, - "done_reason": "stop", - "done": true, - "total_duration": 27351311300, - "load_duration": 8041538400, - "prompt_eval_count": 170, - "prompt_eval_duration": 16078776000, - "eval_count": 19, - "eval_duration": 3227962000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler) { Timeout = Timeout.InfiniteTimeSpan }; - using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient) - { - ToolCallJsonSerializerOptions = TestJsonSerializerContext.Default.Options, - }; - - var response = await client.GetResponseAsync("How old is Alice?", new() - { - Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], - }); - Assert.NotNull(response); - - Assert.Empty(response.Text); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T18:48:30.2669578Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Stop, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(170, response.Usage.InputTokenCount); - Assert.Equal(19, response.Usage.OutputTokenCount); - Assert.Equal(189, response.Usage.TotalTokenCount); - - Assert.Single(response.Messages.Single().Contents); - FunctionCallContent fcc = Assert.IsType(response.Messages.Single().Contents[0]); - Assert.Equal("GetPersonAge", fcc.Name); - AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); - } - - [Fact] - public async Task FunctionResultContent_NonStreaming() - { - const string Input = """ - { - "model": "llama3.1", - "messages": [ - { - "role": "user", - "content": "How old is Alice?" - }, - { - "role": "assistant", - "content": "{\u0022call_id\u0022:\u0022abcd1234\u0022,\u0022name\u0022:\u0022GetPersonAge\u0022,\u0022arguments\u0022:{\u0022personName\u0022:\u0022Alice\u0022}}" - }, - { - "role": "tool", - "content": "{\u0022call_id\u0022:\u0022abcd1234\u0022,\u0022result\u0022:42}" - } - ], - "stream": false, - "tools": [ - { - "type": "function", - "function": { - "name": "GetPersonAge", - "description": "Gets the age of the specified person.", - "parameters": { - "type": "object", - "properties": { - "personName": { - "description": "The person whose age is being requested", - "type": "string" - } - }, - "required": ["personName"] - } - } - } - ] - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T20:57:20.157266Z", - "message": { - "role": "assistant", - "content": "Alice is 42 years old." - }, - "done_reason": "stop", - "done": true, - "total_duration": 20320666000, - "load_duration": 8159642600, - "prompt_eval_count": 106, - "prompt_eval_duration": 10846727000, - "eval_count": 8, - "eval_duration": 1307842000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler) { Timeout = Timeout.InfiniteTimeSpan }; - using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient) - { - ToolCallJsonSerializerOptions = TestJsonSerializerContext.Default.Options, - }; - - var response = await client.GetResponseAsync( - [ - new(ChatRole.User, "How old is Alice?"), - new(ChatRole.Assistant, [new FunctionCallContent("abcd1234", "GetPersonAge", new Dictionary { ["personName"] = "Alice" })]), - new(ChatRole.Tool, [new FunctionResultContent("abcd1234", 42)]), - ], - new() - { - Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], - }); - Assert.NotNull(response); - - Assert.Equal("Alice is 42 years old.", response.Text); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T20:57:20.157266Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Stop, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(106, response.Usage.InputTokenCount); - Assert.Equal(8, response.Usage.OutputTokenCount); - Assert.Equal(114, response.Usage.TotalTokenCount); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs deleted file mode 100644 index 493c0bf0333..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs +++ /dev/null @@ -1,32 +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.Threading.Tasks; -using Microsoft.TestUtilities; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OllamaEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests -{ - protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => - IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? - new OllamaEmbeddingGenerator(endpoint, "all-minilm") : - null; - - [ConditionalFact] - public async Task InvalidModelParameter_ThrowsInvalidOperationException() - { - SkipIfNotEnabled(); - - var endpoint = IntegrationTestHelpers.GetOllamaUri(); - Assert.NotNull(endpoint); - - using var generator = new OllamaEmbeddingGenerator(endpoint, modelId: "inexistent-model"); - - InvalidOperationException ex; - ex = await Assert.ThrowsAsync(() => generator.GenerateAsync(["Hello, world!"])); - Assert.Contains("inexistent-model", ex.Message); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs deleted file mode 100644 index be18138de84..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.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.Net.Http; -using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Xunit; - -#pragma warning disable S103 // Lines should not be too long - -namespace Microsoft.Extensions.AI; - -public class OllamaEmbeddingGeneratorTests -{ - [Fact] - public void Ctor_InvalidArgs_Throws() - { - Assert.Throws("endpoint", () => new OllamaEmbeddingGenerator((string)null!)); - Assert.Throws("modelId", () => new OllamaEmbeddingGenerator(new Uri("http://localhost"), " ")); - } - - [Fact] - public void GetService_SuccessfullyReturnsUnderlyingClient() - { - using OllamaEmbeddingGenerator generator = new("http://localhost"); - - Assert.Same(generator, generator.GetService()); - Assert.Same(generator, generator.GetService>>()); - - using IEmbeddingGenerator> pipeline = generator - .AsBuilder() - .UseOpenTelemetry() - .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Build(); - - Assert.NotNull(pipeline.GetService>>()); - Assert.NotNull(pipeline.GetService>>()); - Assert.NotNull(pipeline.GetService>>()); - - Assert.Same(generator, pipeline.GetService()); - Assert.IsType>>(pipeline.GetService>>()); - } - - [Fact] - public void AsIEmbeddingGenerator_ProducesExpectedMetadata() - { - Uri endpoint = new("http://localhost/some/endpoint"); - string model = "amazingModel"; - - using IEmbeddingGenerator> generator = new OllamaEmbeddingGenerator(endpoint, model); - var metadata = generator.GetService(); - Assert.Equal("ollama", metadata?.ProviderName); - Assert.Equal(endpoint, metadata?.ProviderUri); - Assert.Equal(model, metadata?.DefaultModelId); - } - - [Fact] - public async Task GetEmbeddingsAsync_ExpectedRequestResponse() - { - const string Input = """ - {"model":"all-minilm","input":["hello, world!","red, white, blue"]} - """; - - const string Output = """ - { - "model":"all-minilm", - "embeddings":[ - [-0.038159743,0.032830726,-0.005602915,0.014363416,-0.04031945,-0.11662117,0.031710647,0.0019634133,-0.042558126,0.02925818,0.04254404,0.032178584,0.029820565,0.010947956,-0.05383333,-0.05031401,-0.023460664,0.010746779,-0.13776828,0.003972192,0.029283607,0.06673441,-0.015434976,0.048401773,-0.088160664,-0.012700827,0.04134059,0.0408592,-0.050058633,-0.058048956,0.048720006,0.068883754,0.0588242,0.008813041,-0.016036017,0.08514798,-0.07813561,-0.07740018,0.020856613,0.016228318,0.032506905,-0.053466275,-0.06220645,-0.024293836,0.0073994277,0.02410873,0.006477103,0.051144805,0.072868116,0.03460658,-0.0547553,-0.05937917,-0.007205277,0.020145971,0.035794333,0.005588114,0.010732389,-0.052755248,0.01006711,-0.008716047,-0.062840104,0.038445882,-0.013913384,0.07341423,0.09004691,-0.07995187,-0.016410379,0.044806693,-0.06886798,-0.03302609,-0.015488586,0.0112944925,0.03645402,0.06637969,-0.054364193,0.008732196,0.012049053,-0.038111813,0.006928739,0.05113517,0.07739711,-0.12295967,0.016389083,0.049567502,0.03162499,-0.039604694,0.0016613991,0.009564599,-0.03268798,-0.033994347,-0.13328508,0.0072719813,-0.010261588,0.038570367,-0.093384996,-0.041716397,0.069951184,-0.02632818,-0.149702,0.13445856,0.037486482,0.052814852,0.045044158,0.018727085,0.05445453,0.01727433,-0.032474063,0.046129994,-0.046679277,-0.03058037,-0.0181755,-0.048695795,0.033057086,-0.0038555008,0.050006237,-0.05828653,-0.010029618,0.01062073,-0.040105496,-0.0015263702,0.060846698,-0.04557025,0.049251337,0.026121102,0.019804202,-0.0016694543,0.059516467,-6.525171e-33,0.06351319,0.0030810465,0.028928237,0.17336167,0.0029677018,0.027755935,-0.09513812,-0.031182382,0.026697554,-0.0107956175,0.023849761,0.02378595,-0.03121345,0.049473017,-0.02506533,0.101713106,-0.079133175,-0.0032418896,0.04290832,0.094838716,-0.06652884,0.0062877694,0.02221229,0.0700068,-0.007469806,-0.0017550732,0.027011596,-0.075321496,0.114022695,0.0085597,-0.023766534,-0.04693697,0.014437173,0.01987886,-0.0046902793,0.0013660098,-0.034307938,-0.054156985,-0.09417741,-0.028919358,-0.018871028,0.04574328,0.047602862,-0.0031305805,-0.033291575,-0.0135114025,0.051019657,0.031115327,0.015239397,0.05413997,-0.085031144,0.013366392,-0.04757861,0.07102588,-0.013105953,-0.0023799809,0.050322797,-0.041649505,-0.014187793,0.0324716,0.005401626,0.091307014,0.0044665188,-0.018263677,-0.015284639,-0.04634121,0.038754962,0.014709013,0.052040145,0.0017918312,-0.014979437,0.027103048,0.03117813,0.023749126,-0.004567645,0.03617759,0.06680814,-0.001835277,0.021281,-0.057563916,0.019137124,0.031450257,-0.018432263,-0.040860977,0.10391725,0.011970765,-0.014854915,-0.10521159,-0.012288272,-0.00041675335,-0.09510029,0.058300544,0.042590536,-0.025064372,-0.09454636,4.0064686e-33,0.13224861,0.0053342036,-0.033114634,-0.09096768,-0.031561732,-0.03395822,-0.07202013,0.12591493,-0.08332582,0.052816514,0.001065021,0.022002738,0.1040207,0.013038866,0.04092958,0.018689224,0.1142518,0.024801003,0.014596161,0.006195551,-0.011214642,-0.035760444,-0.037979998,0.011274433,-0.051305123,0.007884909,0.06734877,0.0033462204,-0.09284879,0.037033774,-0.022331867,0.039951596,-0.030730229,-0.011403805,-0.014458028,0.024968812,-0.097553216,-0.03536226,-0.037567392,-0.010149212,-0.06387594,0.025570663,0.02060328,0.037549157,-0.104355134,-0.02837097,-0.052078977,0.0128349,-0.05123587,-0.029060647,-0.09632806,-0.042301137,0.067175224,-0.030890828,-0.010358077,0.027408795,-0.028092034,0.010337195,0.04303845,0.022324203,0.00797792,0.056084383,0.040727936,0.092925824,0.01653155,-0.053750493,0.00046004262,0.050728552,0.04253214,-0.029197674,0.00926312,-0.010662153,-0.037244495,0.002277273,-0.030296732,0.07459592,0.002572513,-0.017561244,0.0028881067,0.03841156,0.007247727,0.045637112,0.039992437,0.014227117,-0.014297474,0.05854321,0.03632371,0.05527864,-0.02007574,-0.08043163,-0.030238612,-0.014929122,0.022335418,0.011954643,-0.06906099,-1.8807288e-8,-0.07850291,0.046684187,-0.023935271,0.063510746,0.024001691,0.0014455577,-0.09078209,-0.066868275,-0.0801402,0.005480386,0.053663295,0.10483363,-0.066864185,0.015531167,0.06711155,0.07081655,-0.031996343,0.020819444,-0.021926524,-0.0073062326,-0.010652819,0.0041180425,0.033138428,-0.0789938,0.03876969,-0.075220205,-0.015715994,0.0059789424,0.005140016,-0.06150612,0.041992374,0.09544083,-0.043187104,0.014401576,-0.10615426,-0.027936764,0.011047429,0.069572434,0.06690283,-0.074798405,-0.07852024,0.04276141,-0.034642085,-0.106051244,-0.03581038,0.051521253,0.06865896,-0.04999753,0.0154549,-0.06452052,-0.07598782,0.02603005,0.074413665,-0.012398757,0.13330704,0.07475513,0.051348723,0.02098748,-0.02679416,0.08896129,0.039944872,-0.041040305,0.031930625,0.018114654], - [0.007228383,-0.021804843,-0.07494023,-0.021707121,-0.021184582,0.09326986,0.10764054,-0.01918113,0.007439991,0.01367952,-0.034187328,-0.044076536,0.016042138,0.007507193,-0.016432272,0.025345335,0.010598066,-0.03832474,-0.14418823,-0.033625234,0.013156937,-0.0048872638,-0.08534306,-0.00003228713,-0.08900276,-0.00008128615,0.010332802,0.053303026,-0.050233904,-0.0879366,-0.064243905,-0.017168961,0.1284308,-0.015268303,-0.049664143,-0.07491954,0.021887481,0.015997978,-0.07967111,0.08744341,-0.039261423,-0.09904984,0.02936398,0.042995434,0.057036504,0.09063012,0.0000012311281,0.06120768,-0.050825767,-0.014443322,0.02879051,-0.002343813,-0.10176559,0.104563184,0.031316753,0.08251861,-0.041213628,-0.0217945,0.0649965,-0.011131547,0.018417398,-0.014460508,-0.05108664,0.11330918,0.01863208,0.006442521,-0.039408617,-0.03609412,-0.009156692,-0.0031261789,-0.010928502,-0.021108521,0.037411734,0.012443921,0.018142054,-0.0362644,0.058286663,-0.02733258,-0.052172586,-0.08320095,-0.07089281,-0.0970049,-0.048587535,0.055343032,0.048351917,0.06892102,-0.039993215,0.06344781,-0.084417015,0.003692423,-0.059397053,0.08186814,0.0029228176,-0.010551637,-0.058019258,0.092128515,0.06862907,-0.06558893,0.021121018,0.079212844,0.09616225,0.0045106052,0.039712362,-0.053576704,0.035097837,-0.04251009,-0.013761404,0.011582285,0.02387105,0.009042205,0.054141942,-0.051263757,-0.07984356,-0.020198742,-0.051623948,-0.0013434993,-0.05825417,-0.0026240738,0.0050159167,-0.06320204,0.07872169,-0.04051374,0.04671058,-0.05804034,-0.07103668,-0.07507343,0.015222599,-3.0948323e-33,0.0076309564,-0.06283016,0.024291662,0.12532257,0.013917241,0.04869009,-0.037988827,-0.035241846,-0.041410565,-0.033772282,0.018835608,0.081035286,-0.049912665,0.044602085,0.030495265,-0.009206943,0.027668765,0.011651487,-0.10254086,0.054472663,-0.06514106,0.12192646,0.048823033,-0.015688669,0.010323047,-0.02821445,-0.030832449,-0.035029083,-0.010604268,0.0014445938,0.08670387,0.01997448,0.0101131955,0.036524937,-0.033489946,-0.026745271,-0.04709222,0.015197909,0.018787097,-0.009976326,-0.0016434817,-0.024719588,-0.09179337,0.09343157,0.029579962,-0.015174558,0.071250066,0.010549244,0.010716396,0.05435638,-0.06391847,-0.031383075,0.007916095,0.012391228,-0.012053197,-0.017409964,0.013742709,0.0594159,-0.033767693,0.04505938,-0.0017214329,0.12797962,0.03223919,-0.054756388,0.025249248,-0.02273578,-0.04701282,-0.018718086,0.009820931,-0.06267794,-0.012644738,0.0068301614,0.093209736,-0.027372226,-0.09436381,0.003861504,0.054960024,-0.058553983,-0.042971537,-0.008994571,-0.08225824,-0.013560626,-0.01880568,0.0995795,-0.040887516,-0.0036491079,-0.010253542,-0.031025425,-0.006957114,-0.038943008,-0.090270124,-0.031345647,0.029613726,-0.099465184,-0.07469079,7.844707e-34,0.024241973,0.03597121,-0.049776066,0.05084303,0.006059542,-0.020719761,0.019962702,0.092246406,0.069408394,0.062306542,0.013837189,0.054749023,0.05090263,0.04100415,-0.02573441,0.09535842,0.036858294,0.059478357,0.0070162765,0.038462427,-0.053635903,0.05912332,-0.037887845,-0.0012995935,-0.068758026,0.0671618,0.029407106,-0.061569903,-0.07481879,-0.01849014,0.014240046,-0.08064838,0.028351007,0.08456427,0.016858438,0.02053254,0.06171099,-0.028964644,-0.047633287,0.08802184,0.0017116248,0.019451816,0.03419083,0.07152118,-0.027244413,-0.04888475,-0.10314279,0.07628554,-0.045991484,-0.023299307,-0.021448445,0.04111079,-0.036342163,-0.010670482,0.01950527,-0.0648448,-0.033299454,0.05782628,0.030278979,0.079154804,-0.03679649,0.031728156,-0.034912236,0.08817754,0.059208114,-0.02319613,-0.027045371,-0.018559752,-0.051946763,-0.010635224,0.048839167,-0.043925915,-0.028300019,-0.0039419765,0.044211324,-0.067469835,-0.027534118,0.005051618,-0.034172326,0.080007285,-0.01931061,-0.005759926,0.08765162,0.08372951,-0.093784876,0.011837292,0.019019455,0.047941882,0.05504541,-0.12475821,0.012822803,0.12833545,0.08005919,0.019278418,-0.025834465,-1.9763878e-8,0.05211108,0.024891146,-0.0015623684,0.0040500895,0.015101377,-0.0031462535,0.014759316,-0.041329216,-0.029255627,0.048599463,0.062482737,0.018376771,-0.066601776,0.014752581,0.07968402,-0.015090815,-0.12100162,-0.0014005995,0.0134423375,-0.0065814927,-0.01188529,-0.01107086,-0.059613306,0.030120188,0.0418596,-0.009260598,0.028435009,0.024893047,0.031339604,0.09501834,0.027570697,0.0636991,-0.056108754,-0.0329521,-0.114633024,-0.00981398,-0.060992315,0.027551433,0.0069592255,-0.059862003,0.0008075791,0.001507554,-0.028574942,-0.011227367,0.0056030746,-0.041190825,-0.09364463,-0.04459479,-0.055058934,-0.029972456,-0.028642913,-0.015199684,0.007875299,-0.034083385,0.02143902,-0.017395096,0.027429376,0.013198211,0.005065835,0.037760753,0.08974973,0.07598824,0.0050444477,0.014734193] - ], - "total_duration":375551700, - "load_duration":354411900, - "prompt_eval_count":9 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IEmbeddingGenerator> generator = new OllamaEmbeddingGenerator("http://localhost:11434", "all-minilm", httpClient); - - var response = await generator.GenerateAsync([ - "hello, world!", - "red, white, blue", - ]); - Assert.NotNull(response); - Assert.Equal(2, response.Count); - - Assert.NotNull(response.Usage); - Assert.Equal(9, response.Usage.InputTokenCount); - Assert.Equal(9, response.Usage.TotalTokenCount); - - foreach (Embedding e in response) - { - Assert.Equal("all-minilm", e.ModelId); - Assert.NotNull(e.CreatedAt); - Assert.Equal(384, e.Vector.Length); - Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs deleted file mode 100644 index 49560a9c451..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs +++ /dev/null @@ -1,12 +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.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -[JsonSerializable(typeof(string))] -[JsonSerializable(typeof(int))] -[JsonSerializable(typeof(IDictionary))] -internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/IntegrationTestHelpers.cs similarity index 100% rename from test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs rename to test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/IntegrationTestHelpers.cs diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj index 14ca7e244d1..d977c035279 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs index 921e2d3b5f9..28d3e21fd65 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs @@ -2,14 +2,115 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.TestUtilities; using OllamaSharp; +using Xunit; namespace Microsoft.Extensions.AI; -public class OllamaSharpChatClientIntegrationTests : OllamaChatClientIntegrationTests +public class OllamaSharpChatClientIntegrationTests : ChatClientIntegrationTests { protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? new OllamaApiClient(endpoint, "llama3.2") : null; + + public override Task FunctionInvocation_RequireAny() => + throw new SkipTestException("Ollama does not currently support requiring function invocation."); + + public override Task FunctionInvocation_RequireSpecific() => + throw new SkipTestException("Ollama does not currently support requiring function invocation."); + + protected override string? GetModel_MultiModal_DescribeImage() => "llava"; + + [ConditionalFact] + public async Task PromptBasedFunctionCalling_NoArgs() + { + SkipIfNotEnabled(); + + using var chatClient = CreateChatClient()! + .AsBuilder() + .UseFunctionInvocation() + .UsePromptBasedFunctionCalling() + .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) + .Build(); + + var secretNumber = 42; + var response = await chatClient.GetResponseAsync("What is the current secret number? Answer with digits only.", new ChatOptions + { + ModelId = "llama3:8b", + Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")], + Temperature = 0, + Seed = 0, + }); + + Assert.Contains(secretNumber.ToString(), response.Text); + } + + [ConditionalFact] + public async Task PromptBasedFunctionCalling_WithArgs() + { + SkipIfNotEnabled(); + + using var chatClient = CreateChatClient()! + .AsBuilder() + .UseFunctionInvocation() + .UsePromptBasedFunctionCalling() + .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) + .Build(); + + var stockPriceTool = AIFunctionFactory.Create([Description("Returns the stock price for a given ticker symbol")] ( + [Description("The ticker symbol")] string symbol, + [Description("The currency code such as USD or JPY")] string currency) => + { + Assert.Equal("MSFT", symbol); + Assert.Equal("GBP", currency); + return 999; + }, "GetStockPrice"); + + var didCallIrrelevantTool = false; + var irrelevantTool = AIFunctionFactory.Create(() => { didCallIrrelevantTool = true; return 123; }, "GetSecretNumber"); + + var response = await chatClient.GetResponseAsync("What's the stock price for Microsoft in British pounds?", new ChatOptions + { + Tools = [stockPriceTool, irrelevantTool], + Temperature = 0, + Seed = 0, + }); + + Assert.Contains("999", response.Text); + Assert.False(didCallIrrelevantTool); + } + + [ConditionalFact] + public async Task InvalidModelParameter_ThrowsInvalidOperationException() + { + SkipIfNotEnabled(); + + var endpoint = IntegrationTestHelpers.GetOllamaUri(); + Assert.NotNull(endpoint); + + using var chatClient = new OllamaApiClient(endpoint, defaultModel: "inexistent-model"); + + InvalidOperationException ex; + ex = await Assert.ThrowsAsync(() => chatClient.GetResponseAsync("Hello, world!")); + Assert.Contains("inexistent-model", ex.Message); + + ex = await Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("Hello, world!").ToChatResponseAsync()); + Assert.Contains("inexistent-model", ex.Message); + } + + private sealed class AssertNoToolsDefinedChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) + { + public override Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Assert.Null(options?.Tools); + return base.GetResponseAsync(messages, options, cancellationToken); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs index 1826855f459..f7775143c36 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs @@ -2,14 +2,35 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Threading.Tasks; +using Microsoft.TestUtilities; using OllamaSharp; +using Xunit; namespace Microsoft.Extensions.AI; -public class OllamaSharpEmbeddingGeneratorIntegrationTests : OllamaEmbeddingGeneratorIntegrationTests +public class OllamaSharpEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests { protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? new OllamaApiClient(endpoint, "all-minilm") : null; + + [ConditionalFact] + public async Task InvalidModelParameter_ThrowsInvalidOperationException() + { + SkipIfNotEnabled(); + + var endpoint = IntegrationTestHelpers.GetOllamaUri(); + Assert.NotNull(endpoint); + + using var client = new OllamaApiClient(endpoint, defaultModel: "inexistent-model"); + + InvalidOperationException ex; + ex = await Assert.ThrowsAsync(() => client.EmbedAsync(new OllamaSharp.Models.EmbedRequest + { + Input = ["Hello, world!"], + })); + Assert.Contains("inexistent-model", ex.Message); + } } From 5610f2622838d5df141ad362e3747429a1fba736 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 24 Jul 2025 18:50:30 -0400 Subject: [PATCH 45/71] Fix M.E.AI package refs (#6654) --- .../Microsoft.Extensions.AI.AzureAIInference.csproj | 3 +++ .../Microsoft.Extensions.AI.OpenAI.csproj | 3 +++ .../Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj | 3 +++ 3 files changed, 9 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj index 5384a7992d7..06690c96eaf 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj @@ -29,6 +29,9 @@ + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj index a135ee011ea..ce5a5fd89c0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj @@ -33,6 +33,9 @@ + + + diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 6331b3c1149..fe431ca21e5 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -44,6 +44,9 @@ + + + From 8f0f5a7a1c061d80c0bc1b88db4171ea3186252f Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 25 Jul 2025 06:40:29 -0400 Subject: [PATCH 46/71] Add [Description] to DataContent.Uri (#6615) So that if a DataContent has schema generated for it, it's obvious in the schema that the "uri" is specifically a data URI. --- .../Contents/DataContent.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs index 57f7fac1962..0c11973381b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -5,6 +5,7 @@ #if NET using System.Buffers; using System.Buffers.Text; +using System.ComponentModel; #endif using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -142,6 +143,9 @@ public DataContent(ReadOnlyMemory data, string mediaType) /// or from a . /// [StringSyntax(StringSyntaxAttribute.Uri)] +#if NET + [Description("A data URI representing the content.")] +#endif public string Uri { get From 93bebed85940be82a005b84c55057bb4de18e6cc Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 25 Jul 2025 09:04:44 -0400 Subject: [PATCH 47/71] Fix duplicate solution file when creating an AI Chat Web app from VS (#6653) --- .../ChatWithCustomData/.template.config/template.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json index 2f9a293b32e..caeceae1dde 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json @@ -30,7 +30,7 @@ "path": "./ChatWithCustomData-CSharp.csproj" }, { - "condition": "(IsAspire && (HostIdentifier == \"dotnetcli\" || HostIdentifier == \"dotnetcli-preview\"))", + "condition": "(IsAspire && (hostIdentifier == \"dotnetcli\" || hostIdentifier == \"dotnetcli-preview\"))", "path": "./ChatWithCustomData-CSharp.sln" }, { @@ -75,6 +75,12 @@ "README.Aspire.md": "README.md" } }, + { + "condition": "(IsAspire && hostIdentifier != \"dotnetcli\" && hostIdentifier != \"dotnetcli-preview\")", + "exclude": [ + "*.sln" + ] + }, { "condition": "(!UseLocalVectorStore)", "exclude": [ @@ -558,7 +564,7 @@ } }, "postActions": [{ - "condition": "(hostIdentifier != \"dotnetcli\")", + "condition": "(hostIdentifier != \"dotnetcli\" && hostIdentifier != \"dotnetcli-preview\")", "description": "Opens README file in the editor", "manualInstructions": [ ], "actionId": "84C0DA21-51C8-4541-9940-6CA19AF04EE6", From 7798311d745dd90ebb474bfa9886b186ab8a2c41 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 25 Jul 2025 09:12:39 -0400 Subject: [PATCH 48/71] Add ChatMessage.CreatedAt (#6657) We currently have it at the ChatResponse{Update} level, but for more agentic scenarios, it's helpful to have a timestamp per message. --- .../ChatCompletion/ChatMessage.cs | 4 ++ .../ChatCompletion/ChatResponse.cs | 12 ++-- .../ChatCompletion/ChatResponseExtensions.cs | 11 +++- .../Microsoft.Extensions.AI.Abstractions.json | 4 ++ .../AzureAIInferenceChatClient.cs | 1 + .../OpenAIChatClient.cs | 1 + .../OpenAIResponsesChatClient.cs | 5 ++ .../ChatCompletion/ChatMessageTests.cs | 17 ++++++ .../ChatCompletion/ChatResponseTests.cs | 53 ++++++++++++++++- .../ChatResponseUpdateExtensionsTests.cs | 59 +++++++++++++++++++ 10 files changed, 158 insertions(+), 9 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs index 43b3e321df9..9e36f548c00 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs @@ -53,6 +53,7 @@ public ChatMessage Clone() => AdditionalProperties = AdditionalProperties, _authorName = _authorName, _contents = _contents, + CreatedAt = CreatedAt, RawRepresentation = RawRepresentation, Role = Role, MessageId = MessageId, @@ -65,6 +66,9 @@ public string? AuthorName set => _authorName = string.IsNullOrWhiteSpace(value) ? null : value; } + /// Gets or sets a timestamp for the chat message. + public DateTimeOffset? CreatedAt { get; set; } + /// Gets or sets the role of the author of the message. public ChatRole Role { get; set; } = ChatRole.User; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs index a342ef1e69e..0889fed17d6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs @@ -130,19 +130,19 @@ public ChatResponseUpdate[] ToChatResponseUpdates() ChatMessage message = _messages![i]; updates[i] = new ChatResponseUpdate { - ConversationId = ConversationId, - AdditionalProperties = message.AdditionalProperties, AuthorName = message.AuthorName, Contents = message.Contents, + MessageId = message.MessageId, RawRepresentation = message.RawRepresentation, Role = message.Role, - ResponseId = ResponseId, - MessageId = message.MessageId, - CreatedAt = CreatedAt, + ConversationId = ConversationId, FinishReason = FinishReason, - ModelId = ModelId + ModelId = ModelId, + ResponseId = ResponseId, + + CreatedAt = message.CreatedAt ?? CreatedAt, }; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs index 01ce878e79c..6691c665c54 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs @@ -84,9 +84,10 @@ public static void AddMessages(this IList list, ChatResponseUpdate var contentsList = filter is null ? update.Contents : update.Contents.Where(filter).ToList(); if (contentsList.Count > 0) { - list.Add(new ChatMessage(update.Role ?? ChatRole.Assistant, contentsList) + list.Add(new(update.Role ?? ChatRole.Assistant, contentsList) { AuthorName = update.AuthorName, + CreatedAt = update.CreatedAt, RawRepresentation = update.RawRepresentation, AdditionalProperties = update.AdditionalProperties, }); @@ -268,7 +269,7 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon if (isNewMessage) { - message = new ChatMessage(ChatRole.Assistant, []); + message = new(ChatRole.Assistant, []); response.Messages.Add(message); } else @@ -280,11 +281,17 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon // Incorporate those into the latest message; in cases where the message // stores a single value, prefer the latest update's value over anything // stored in the message. + if (update.AuthorName is not null) { message.AuthorName = update.AuthorName; } + if (update.CreatedAt is not null) + { + message.CreatedAt = update.CreatedAt; + } + if (update.Role is ChatRole role) { message.Role = role; 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 4d190fccda8..9562de0d93f 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 @@ -883,6 +883,10 @@ "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.ChatMessage.Contents { get; set; }", "Stage": "Stable" }, + { + "Member": "System.DateTimeOffset? Microsoft.Extensions.AI.ChatMessage.CreatedAt { get; set; }", + "Stage": "Stable" + }, { "Member": "string? Microsoft.Extensions.AI.ChatMessage.MessageId { get; set; }", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index bb23e1de489..0fd8f4506db 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -95,6 +95,7 @@ public async Task GetResponseAsync( // Create the return message. ChatMessage message = new(ToChatRole(response.Role), response.Content) { + CreatedAt = response.Created, MessageId = response.Id, // There is no per-message ID, but there's only one message per response, so use the response ID RawRepresentation = response, }; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 9fc2f3cbeef..a70fcec2a8f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -438,6 +438,7 @@ internal static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompl // Create the return message. ChatMessage returnMessage = new() { + CreatedAt = openAICompletion.CreatedAt, MessageId = openAICompletion.Id, // There's no per-message ID, so we use the same value as the response ID RawRepresentation = openAICompletion, Role = FromOpenAIChatRole(openAICompletion.Role), diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 60d0e10a159..dfe0c50d374 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -162,6 +162,11 @@ internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, R } } + foreach (var message in response.Messages) + { + message.CreatedAt = openAIResponse.CreatedAt; + } + return response; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs index c449f064255..7fd9591a6ae 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs @@ -18,6 +18,7 @@ public void Constructor_Parameterless_PropsDefaulted() ChatMessage message = new(); Assert.Null(message.AuthorName); Assert.Empty(message.Contents); + Assert.Null(message.CreatedAt); Assert.Equal(ChatRole.User, message.Role); Assert.Empty(message.Text); Assert.NotNull(message.Contents); @@ -50,6 +51,7 @@ public void Constructor_RoleString_PropsRoundtrip(string? text) } Assert.Null(message.AuthorName); + Assert.Null(message.CreatedAt); Assert.Null(message.RawRepresentation); Assert.Null(message.AdditionalProperties); Assert.Equal(text ?? string.Empty, message.ToString()); @@ -113,6 +115,7 @@ public void Constructor_RoleList_PropsRoundtrip(int messageCount) } Assert.Null(message.AuthorName); + Assert.Null(message.CreatedAt); Assert.Null(message.RawRepresentation); Assert.Null(message.AdditionalProperties); } @@ -230,6 +233,20 @@ public void AdditionalProperties_Roundtrips() Assert.Same(props, message.AdditionalProperties); } + [Fact] + public void CreatedAt_Roundtrips() + { + ChatMessage message = new(); + Assert.Null(message.CreatedAt); + + DateTimeOffset now = DateTimeOffset.Now; + message.CreatedAt = now; + Assert.Equal(now, message.CreatedAt); + + message.CreatedAt = null; + Assert.Null(message.CreatedAt); + } + [Fact] public void ItCanBeSerializeAndDeserialized() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs index 802b414437d..de5809d3d97 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs @@ -125,7 +125,7 @@ public void ToString_OutputsText() } [Fact] - public void ToChatResponseUpdates() + public void ToChatResponseUpdates_SingleMessage() { ChatResponse response = new(new ChatMessage(new ChatRole("customRole"), "Text") { MessageId = "someMessage" }) { @@ -153,4 +153,55 @@ public void ToChatResponseUpdates() Assert.Equal("value1", update1.AdditionalProperties?["key1"]); Assert.Equal(42, update1.AdditionalProperties?["key2"]); } + + [Fact] + public void ToChatResponseUpdates_MultipleMessages() + { + ChatResponse response = new( + [ + new ChatMessage(new ChatRole("customRole"), "Text") + { + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + MessageId = "someMessage" + }, + new ChatMessage(new ChatRole("secondRole"), "Another message") + { + CreatedAt = new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero), + MessageId = "anotherMessage" + } + ]) + { + ResponseId = "12345", + ModelId = "someModel", + FinishReason = ChatFinishReason.ContentFilter, + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 }, + }; + + ChatResponseUpdate[] updates = response.ToChatResponseUpdates(); + Assert.NotNull(updates); + Assert.Equal(3, updates.Length); + + ChatResponseUpdate update0 = updates[0]; + Assert.Equal("12345", update0.ResponseId); + Assert.Equal("someMessage", update0.MessageId); + Assert.Equal("someModel", update0.ModelId); + Assert.Equal(ChatFinishReason.ContentFilter, update0.FinishReason); + Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt); + Assert.Equal("customRole", update0.Role?.Value); + Assert.Equal("Text", update0.Text); + + ChatResponseUpdate update1 = updates[1]; + Assert.Equal("12345", update1.ResponseId); + Assert.Equal("anotherMessage", update1.MessageId); + Assert.Equal("someModel", update1.ModelId); + Assert.Equal(ChatFinishReason.ContentFilter, update1.FinishReason); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero), update1.CreatedAt); + Assert.Equal("secondRole", update1.Role?.Value); + Assert.Equal("Another message", update1.Text); + + ChatResponseUpdate update2 = updates[2]; + Assert.Equal("value1", update2.AdditionalProperties?["key1"]); + Assert.Equal(42, update2.AdditionalProperties?["key2"]); + } } 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 8b13d640ae1..2eb8db9b477 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs @@ -65,6 +65,65 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync) Assert.Equal("Hello, world!", response.Text); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_UpdatesProduceMultipleResponseMessages(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message - ID "msg1" + new(null, "Hi! ") { CreatedAt = new DateTimeOffset(2023, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" }, + new(ChatRole.Assistant, "Hello") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" }, + new(null, " from") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero) }, // Later CreatedAt should win + new(null, " AI") { MessageId = "msg1", AuthorName = "AI Assistant" }, // Later AuthorName should win + + // Second message - ID "msg2" + new(ChatRole.User, "How") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 0, 0, TimeSpan.Zero), AuthorName = "User" }, + new(null, " are") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero) }, + new(null, " you?") { MessageId = "msg2", AuthorName = "Human User" }, // Later AuthorName should win + + // Third message - ID "msg3" + new(ChatRole.Assistant, "I'm doing well,") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero) }, + new(null, " thank you!") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero) }, // Later CreatedAt should win + + // Updates without MessageId should continue the last message (msg3) + new(null, " How can I help?"), + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.NotNull(response); + Assert.Equal(3, response.Messages.Count); + + // Verify first message + ChatMessage message1 = response.Messages[0]; + Assert.Equal("msg1", message1.MessageId); + Assert.Equal(ChatRole.Assistant, message1.Role); + Assert.Equal("AI Assistant", message1.AuthorName); // Last value should win + Assert.Equal(new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero), message1.CreatedAt); // Last value should win + Assert.Equal("Hi! Hello from AI", message1.Text); + + // Verify second message + ChatMessage message2 = response.Messages[1]; + Assert.Equal("msg2", message2.MessageId); + Assert.Equal(ChatRole.User, message2.Role); + Assert.Equal("Human User", message2.AuthorName); // Last value should win + Assert.Equal(new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero), message2.CreatedAt); // Last value should win + Assert.Equal("How are you?", message2.Text); + + // Verify third message + ChatMessage message3 = response.Messages[2]; + Assert.Equal("msg3", message3.MessageId); + Assert.Equal(ChatRole.Assistant, message3.Role); + Assert.Null(message3.AuthorName); // No AuthorName set in later updates + Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero), message3.CreatedAt); // Last value should win + Assert.Equal("I'm doing well, thank you! How can I help?", message3.Text); + } + public static IEnumerable ToChatResponse_Coalescing_VariousSequenceAndGapLengths_MemberData() { foreach (bool useAsync in new[] { false, true }) From ff0619122925f0a4d311796a08859e609543b0ad Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 28 Jul 2025 09:23:44 -0400 Subject: [PATCH 49/71] Add TextContent.Annotations (#6619) * Add TextContent.Annotations --- .../ChatCompletion/ChatResponseExtensions.cs | 23 +++- .../Contents/AIAnnotation.cs | 43 ++++++++ .../Contents/AIAnnotationExtensions.cs | 0 .../Contents/AIAnnotationKind.cs | 0 .../Contents/AIAnnotationReference.cs | 0 .../Contents/AIContent.cs | 6 ++ .../Contents/AnnotatedRegion.cs | 23 ++++ .../Contents/CitationAnnotation.cs | 53 ++++++++++ .../Contents/TextSpanAnnotatedRegion.cs | 32 ++++++ .../Microsoft.Extensions.AI.Abstractions.json | 90 ++++++++++++++++ .../OpenAIAssistantsChatClient.cs | 36 ++++++- .../OpenAIChatClient.cs | 25 +++++ .../OpenAIResponsesChatClient.cs | 21 +++- .../ChatResponseUpdateExtensionsTests.cs | 42 ++++++++ .../Contents/AIAnnotationTests.cs | 71 +++++++++++++ .../Contents/CitationAnnotationTests.cs | 100 ++++++++++++++++++ .../Utilities/AIJsonUtilitiesTests.cs | 3 +- .../ChatClientIntegrationTests.cs | 76 ++++++------- .../IntegrationTestHelpers.cs | 2 +- .../OpenAIResponseClientIntegrationTests.cs | 33 ++++++ 20 files changed, 631 insertions(+), 48 deletions(-) delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationExtensions.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationKind.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationReference.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AnnotatedRegion.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CitationAnnotation.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextSpanAnnotatedRegion.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CitationAnnotationTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs index 6691c665c54..667a9d9aea1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using System.Threading; @@ -197,14 +198,16 @@ static void Coalesce(List contents, Func int start = 0; while (start < contents.Count - 1) { - // We need at least two TextContents in a row to be able to coalesce. - if (contents[start] is not TContent firstText) + // We need at least two TextContents in a row to be able to coalesce. We also avoid touching contents + // that have annotations, as we want to ensure the annotations (and in particular any start/end indices + // into the text content) remain accurate. + if (!TryAsCoalescable(contents[start], out var firstText)) { start++; continue; } - if (contents[start + 1] is not TContent secondText) + if (!TryAsCoalescable(contents[start + 1], out var secondText)) { start += 2; continue; @@ -216,7 +219,7 @@ static void Coalesce(List contents, Func _ = coalescedText.Clear().Append(firstText).Append(secondText); contents[start + 1] = null!; int i = start + 2; - for (; i < contents.Count && contents[i] is TContent next; i++) + for (; i < contents.Count && TryAsCoalescable(contents[i], out TContent? next); i++) { _ = coalescedText.Append(next); contents[i] = null!; @@ -230,6 +233,18 @@ static void Coalesce(List contents, Func newContent.AdditionalProperties = firstText.AdditionalProperties?.Clone(); start = i; + + static bool TryAsCoalescable(AIContent content, [NotNullWhen(true)] out TContent? coalescable) + { + if (content is TContent && (content is not TextContent tc || tc.Annotations is not { Count: > 0 })) + { + coalescable = (TContent)content; + return true; + } + + coalescable = null!; + return false; + } } // Remove all of the null slots left over from the coalescing process. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs index e69de29bb2d..73fdff81aa2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.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.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an annotation on content. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(CitationAnnotation), typeDiscriminator: "citation")] +public class AIAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + public AIAnnotation() + { + } + + /// Gets or sets any target regions for the annotation, pointing to where in the associated this annotation applies. + /// + /// The most common form of is , which provides starting and ending character indices + /// for . + /// + public IList? AnnotatedRegions { get; set; } + + /// Gets or sets the raw representation of the annotation from an underlying implementation. + /// + /// If an 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 metadata specific to the provider or source type. + /// + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationExtensions.cs deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationKind.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationKind.cs deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationReference.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotationReference.cs deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs index 06798f10f3d..5d0baf93957 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI; @@ -24,6 +25,11 @@ public AIContent() { } + /// + /// Gets or sets a list of annotations on this content. + /// + public IList? Annotations { get; set; } + /// Gets or sets the raw representation of the content from an underlying implementation. /// /// If an is created to represent some underlying object from another object diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AnnotatedRegion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AnnotatedRegion.cs new file mode 100644 index 00000000000..fed6dc886b7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AnnotatedRegion.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Describes the portion of an associated to which an annotation applies. +/// +/// Details about the region is provided by derived types based on how the region is described. For example, starting +/// and ending indices into text content are provided by . +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(TextSpanAnnotatedRegion), typeDiscriminator: "textSpan")] +public class AnnotatedRegion +{ + /// + /// Initializes a new instance of the class. + /// + public AnnotatedRegion() + { + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CitationAnnotation.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CitationAnnotation.cs new file mode 100644 index 00000000000..5d1d2f88b30 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CitationAnnotation.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an annotation that links content to source references, +/// such as documents, URLs, files, or tool outputs. +/// +public class CitationAnnotation : AIAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + public CitationAnnotation() + { + } + + /// + /// Gets or sets the title or name of the source. + /// + /// + /// This could be the title of a document, a title from a web page, a name of a file, or similarly descriptive text. + /// + public string? Title { get; set; } + + /// + /// Gets or sets a URI from which the source material was retrieved. + /// + public Uri? Url { get; set; } + + /// Gets or sets a source identifier associated with the annotation. + /// + /// This is a provider-specific identifier that can be used to reference the source material by + /// an ID. This may be a document ID, or a file ID, or some other identifier for the source material + /// that can be used to uniquely identify it with the provider. + /// + public string? FileId { get; set; } + + /// Gets or sets the name of any tool involved in the production of the associated content. + /// + /// This might be a function name, such as one from , or the name of a built-in tool + /// from the provider, such as "code_interpreter" or "file_search". + /// + public string? ToolName { get; set; } + + /// + /// Gets or sets a snippet or excerpt from the source that was cited. + /// + public string? Snippet { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextSpanAnnotatedRegion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextSpanAnnotatedRegion.cs new file mode 100644 index 00000000000..8ce3dbfa3c5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextSpanAnnotatedRegion.cs @@ -0,0 +1,32 @@ +// 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; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Describes a location in the associated based on starting and ending character indices. +/// This typically applies to . +[DebuggerDisplay("[{StartIndex}, {EndIndex})")] +public sealed class TextSpanAnnotatedRegion : AnnotatedRegion +{ + /// + /// Initializes a new instance of the class. + /// + public TextSpanAnnotatedRegion() + { + } + + /// + /// Gets or sets the start character index (inclusive) of the annotated span in the . + /// + [JsonPropertyName("start")] + public int? StartIndex { get; set; } + + /// + /// Gets or sets the end character index (exclusive) of the annotated span in the . + /// + [JsonPropertyName("end")] + public int? EndIndex { 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 9562de0d93f..81dcda0bc8d 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 @@ -123,6 +123,30 @@ } ] }, + { + "Type": "class Microsoft.Extensions.AI.AIAnnotation", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIAnnotation.AIAnnotation();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.AIAnnotation.AnnotatedRegions { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.AIAnnotation.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.AIAnnotation.RawRepresentation { get; set; }", + "Stage": "Stable" + } + ] + }, { "Type": "class Microsoft.Extensions.AI.AIContent", "Stage": "Stable", @@ -137,6 +161,10 @@ "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.AIContent.AdditionalProperties { get; set; }", "Stage": "Stable" }, + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.AIContent.Annotations { get; set; }", + "Stage": "Stable" + }, { "Member": "object? Microsoft.Extensions.AI.AIContent.RawRepresentation { get; set; }", "Stage": "Stable" @@ -653,6 +681,16 @@ } ] }, + { + "Type": "class Microsoft.Extensions.AI.AnnotatedRegion", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AnnotatedRegion.AnnotatedRegion();", + "Stage": "Stable" + } + ] + }, { "Type": "sealed class Microsoft.Extensions.AI.AutoChatToolMode : Microsoft.Extensions.AI.ChatToolMode", "Stage": "Stable", @@ -1323,6 +1361,38 @@ } ] }, + { + "Type": "class Microsoft.Extensions.AI.CitationAnnotation : Microsoft.Extensions.AI.AIAnnotation", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.CitationAnnotation.CitationAnnotation();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.Title { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.ToolName { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Uri? Microsoft.Extensions.AI.CitationAnnotation.Url { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.FileId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.Snippet { get; set; }", + "Stage": "Stable" + } + ] + }, { "Type": "class Microsoft.Extensions.AI.DataContent : Microsoft.Extensions.AI.AIContent", "Stage": "Stable", @@ -2273,6 +2343,26 @@ } ] }, + { + "Type": "sealed class Microsoft.Extensions.AI.TextSpanAnnotatedRegion : Microsoft.Extensions.AI.AnnotatedRegion", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.TextSpanAnnotatedRegion.TextSpanAnnotatedRegion();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "int? Microsoft.Extensions.AI.TextSpanAnnotatedRegion.StartIndex { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.AI.TextSpanAnnotatedRegion.EndIndex { get; set; }", + "Stage": "Stable" + } + ] + }, { "Type": "class Microsoft.Extensions.AI.UriContent : Microsoft.Extensions.AI.AIContent", "Stage": "Stable", diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs index 46bc39ee278..c437a553d28 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs @@ -210,7 +210,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( break; case MessageContentUpdate mcu: - yield return new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text) + ChatResponseUpdate textUpdate = new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text) { AuthorName = _assistantId, ConversationId = threadId, @@ -218,10 +218,42 @@ public async IAsyncEnumerable GetStreamingResponseAsync( RawRepresentation = mcu, ResponseId = responseId, }; + + // Add any annotations from the text update. The OpenAI Assistants API does not support passing these back + // into the model (MessageContent.FromXx does not support providing annotations), so they end up being one way and are dropped + // on subsequent requests. + if (mcu.TextAnnotation is { } tau) + { + string? fileId = null; + string? toolName = null; + if (!string.IsNullOrWhiteSpace(tau.InputFileId)) + { + fileId = tau.InputFileId; + toolName = "file_search"; + } + else if (!string.IsNullOrWhiteSpace(tau.OutputFileId)) + { + fileId = tau.OutputFileId; + toolName = "code_interpreter"; + } + + if (fileId is not null) + { + (((TextContent)textUpdate.Contents[0]).Annotations ??= []).Add(new CitationAnnotation + { + RawRepresentation = tau, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = tau.StartIndex, EndIndex = tau.EndIndex }], + FileId = fileId, + ToolName = toolName, + }); + } + } + + yield return textUpdate; break; default: - yield return new ChatResponseUpdate + yield return new() { AuthorName = _assistantId, ConversationId = threadId, diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index a70fcec2a8f..8a4e08cac9f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -480,6 +481,30 @@ internal static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompl returnMessage.Contents.Add(new ErrorContent(refusal) { ErrorCode = nameof(openAICompletion.Refusal) }); } + // And add annotations. OpenAI chat completion specifies annotations at the message level (and as such they can't be + // roundtripped back); we store them either on the first text content, assuming there is one, or on a dedicated content + // instance if not. + if (openAICompletion.Annotations is { Count: > 0 }) + { + TextContent? annotationContent = returnMessage.Contents.OfType().FirstOrDefault(); + if (annotationContent is null) + { + annotationContent = new(null); + returnMessage.Contents.Add(annotationContent); + } + + foreach (var annotation in openAICompletion.Annotations) + { + (annotationContent.Annotations ??= []).Add(new CitationAnnotation + { + RawRepresentation = annotation, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = annotation.StartIndex, EndIndex = annotation.EndIndex }], + Title = annotation.WebResourceTitle, + Url = annotation.WebResourceUri, + }); + } + } + // Wrap the content in a ChatResponse to return. var response = new ChatResponse(returnMessage) { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index dfe0c50d374..5bd3529e292 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -608,10 +608,27 @@ private static List ToAIContents(IEnumerable con switch (part.Kind) { case ResponseContentPartKind.OutputText: - results.Add(new TextContent(part.Text) + TextContent text = new(part.Text) { RawRepresentation = part, - }); + }; + + if (part.OutputTextAnnotations is { Count: > 0 }) + { + foreach (var ota in part.OutputTextAnnotations) + { + (text.Annotations ??= []).Add(new CitationAnnotation + { + RawRepresentation = ota, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = ota.UriCitationStartIndex, EndIndex = ota.UriCitationEndIndex }], + Title = ota.UriCitationTitle, + Url = ota.UriCitationUri, + FileId = ota.FileCitationFileId ?? ota.FilePathFileId, + }); + } + } + + results.Add(text); break; case ResponseContentPartKind.Refusal: 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 2eb8db9b477..45a82542da8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs @@ -242,6 +242,48 @@ public async Task ToChatResponse_CoalescesTextContentAndTextReasoningContentSepa Assert.Equal("OP", Assert.IsType(message.Contents[7]).Text); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_DoesNotCoalesceAnnotatedContent(bool useAsync) + { + ChatResponseUpdate[] updates = + { + new(null, "A"), + new(null, "B"), + new(null, "C"), + new() { Contents = [new TextContent("D") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("E") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("F") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("G") { Annotations = [] }] }, + new() { Contents = [new TextContent("H") { Annotations = [] }] }, + new() { Contents = [new TextContent("I") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("J") { Annotations = [new()] }] }, + new(null, "K"), + new() { Contents = [new TextContent("L") { Annotations = [new()] }] }, + new(null, "M"), + new(null, "N"), + new() { Contents = [new TextContent("O") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("P") { Annotations = [new()] }] }, + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(12, message.Contents.Count); + Assert.Equal("ABC", Assert.IsType(message.Contents[0]).Text); + Assert.Equal("D", Assert.IsType(message.Contents[1]).Text); + Assert.Equal("E", Assert.IsType(message.Contents[2]).Text); + Assert.Equal("F", Assert.IsType(message.Contents[3]).Text); + Assert.Equal("GH", Assert.IsType(message.Contents[4]).Text); + Assert.Equal("I", Assert.IsType(message.Contents[5]).Text); + Assert.Equal("J", Assert.IsType(message.Contents[6]).Text); + Assert.Equal("K", Assert.IsType(message.Contents[7]).Text); + Assert.Equal("L", Assert.IsType(message.Contents[8]).Text); + Assert.Equal("MN", Assert.IsType(message.Contents[9]).Text); + Assert.Equal("O", Assert.IsType(message.Contents[10]).Text); + Assert.Equal("P", Assert.IsType(message.Contents[11]).Text); + } + [Fact] public async Task ToChatResponse_UsageContentExtractedFromContents() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.cs index e69de29bb2d..2b4b23f0a72 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.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; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIAnnotationTests +{ + [Fact] + public void Constructor_PropsDefault() + { + AIAnnotation a = new(); + Assert.Null(a.AdditionalProperties); + Assert.Null(a.RawRepresentation); + Assert.Null(a.AnnotatedRegions); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + AIAnnotation a = new(); + + Assert.Null(a.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + a.AdditionalProperties = props; + Assert.Same(props, a.AdditionalProperties); + + Assert.Null(a.AnnotatedRegions); + List regions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }]; + a.AnnotatedRegions = regions; + Assert.Same(regions, a.AnnotatedRegions); + + Assert.Null(a.RawRepresentation); + object raw = new(); + a.RawRepresentation = raw; + Assert.Same(raw, a.RawRepresentation); + } + + [Fact] + public void Serialization_Roundtrips() + { + AIAnnotation original = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary { { "key", "value" } }, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }], + RawRepresentation = new object(), + }; + + string json = JsonSerializer.Serialize(original, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIAnnotation))); + Assert.NotNull(json); + + var deserialized = (AIAnnotation?)JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIAnnotation))); + Assert.NotNull(deserialized); + + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Single(deserialized.AdditionalProperties); + Assert.Equal(JsonSerializer.Deserialize("\"value\"", AIJsonUtilities.DefaultOptions).ToString(), deserialized.AdditionalProperties["key"]!.ToString()); + + Assert.Null(deserialized.RawRepresentation); + + Assert.NotNull(deserialized.AnnotatedRegions); + TextSpanAnnotatedRegion? region = Assert.IsType(Assert.Single(deserialized.AnnotatedRegions)); + Assert.NotNull(region); + Assert.Equal(10, region.StartIndex); + Assert.Equal(42, region.EndIndex); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CitationAnnotationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CitationAnnotationTests.cs new file mode 100644 index 00000000000..08097f3e05e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CitationAnnotationTests.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.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class CitationAnnotationTests +{ + [Fact] + public void Constructor_PropsDefault() + { + CitationAnnotation a = new(); + Assert.Null(a.AdditionalProperties); + Assert.Null(a.AnnotatedRegions); + Assert.Null(a.RawRepresentation); + Assert.Null(a.Snippet); + Assert.Null(a.Title); + Assert.Null(a.ToolName); + Assert.Null(a.Url); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + CitationAnnotation a = new(); + + Assert.Null(a.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + a.AdditionalProperties = props; + Assert.Same(props, a.AdditionalProperties); + + Assert.Null(a.RawRepresentation); + object raw = new(); + a.RawRepresentation = raw; + Assert.Same(raw, a.RawRepresentation); + + Assert.Null(a.AnnotatedRegions); + List regions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }]; + a.AnnotatedRegions = regions; + Assert.Same(regions, a.AnnotatedRegions); + + Assert.Null(a.Snippet); + a.Snippet = "snippet"; + Assert.Equal("snippet", a.Snippet); + + Assert.Null(a.Title); + a.Title = "title"; + Assert.Equal("title", a.Title); + + Assert.Null(a.ToolName); + a.ToolName = "toolName"; + Assert.Equal("toolName", a.ToolName); + + Assert.Null(a.Url); + Uri url = new("https://example.com"); + a.Url = url; + Assert.Same(url, a.Url); + } + + [Fact] + public void Serialization_Roundtrips() + { + CitationAnnotation original = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary { { "key", "value" } }, + RawRepresentation = new object(), + Snippet = "snippet", + Title = "title", + ToolName = "toolName", + Url = new("https://example.com"), + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }], + }; + + string json = JsonSerializer.Serialize(original, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CitationAnnotation))); + Assert.NotNull(json); + + var deserialized = (CitationAnnotation?)JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CitationAnnotation))); + Assert.NotNull(deserialized); + + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Single(deserialized.AdditionalProperties); + Assert.Equal(JsonSerializer.Deserialize("\"value\"", AIJsonUtilities.DefaultOptions).ToString(), deserialized.AdditionalProperties["key"]!.ToString()); + + Assert.Null(deserialized.RawRepresentation); + Assert.Equal("snippet", deserialized.Snippet); + Assert.Equal("title", deserialized.Title); + Assert.Equal("toolName", deserialized.ToolName); + Assert.NotNull(deserialized.AnnotatedRegions); + TextSpanAnnotatedRegion region = Assert.IsType(Assert.Single(deserialized.AnnotatedRegions)); + Assert.Equal(10, region.StartIndex); + Assert.Equal(42, region.EndIndex); + + Assert.NotNull(deserialized.Url); + Assert.Equal(original.Url, deserialized.Url); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index c2177486fea..acbb5515085 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -943,13 +943,14 @@ public static void AddAIContentType_DerivedAIContent() JsonSerializerOptions options = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; options.AddAIContentType("derivativeContent"); AIContent c = new DerivedAIContent { DerivedValue = 42 }; string json = JsonSerializer.Serialize(c, options); - Assert.Equal("""{"$type":"derivativeContent","DerivedValue":42,"AdditionalProperties":null}""", json); + Assert.Equal("""{"$type":"derivativeContent","DerivedValue":42}""", json); AIContent? deserialized = JsonSerializer.Deserialize(json, options); Assert.IsType(deserialized); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index c9f0e09abf3..5f7f3769d21 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -34,16 +34,16 @@ namespace Microsoft.Extensions.AI; public abstract class ChatClientIntegrationTests : IDisposable { - private readonly IChatClient? _chatClient; - protected ChatClientIntegrationTests() { - _chatClient = CreateChatClient(); + ChatClient = CreateChatClient(); } + protected IChatClient? ChatClient { get; } + public void Dispose() { - _chatClient?.Dispose(); + ChatClient?.Dispose(); GC.SuppressFinalize(this); } @@ -54,7 +54,7 @@ public virtual async Task GetResponseAsync_SingleRequestMessage() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync("What's the biggest animal?"); + var response = await ChatClient.GetResponseAsync("What's the biggest animal?"); Assert.Contains("whale", response.Text, StringComparison.OrdinalIgnoreCase); } @@ -64,7 +64,7 @@ public virtual async Task GetResponseAsync_MultipleRequestMessages() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.User, "Pick a city, any city"), new(ChatRole.Assistant, "Seattle"), @@ -82,7 +82,7 @@ public virtual async Task GetResponseAsync_WithEmptyMessage() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.System, []), new(ChatRole.User, []), @@ -104,7 +104,7 @@ public virtual async Task GetStreamingResponseAsync() ]; StringBuilder sb = new(); - await foreach (var chunk in _chatClient.GetStreamingResponseAsync(chatHistory)) + await foreach (var chunk in ChatClient.GetStreamingResponseAsync(chatHistory)) { sb.Append(chunk.Text); } @@ -119,7 +119,7 @@ public virtual async Task GetResponseAsync_UsageDataAvailable() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync("Explain in 10 words how AI works"); + var response = await ChatClient.GetResponseAsync("Explain in 10 words how AI works"); Assert.True(response.Usage?.InputTokenCount > 1); Assert.True(response.Usage?.OutputTokenCount > 1); @@ -131,7 +131,7 @@ public virtual async Task GetStreamingResponseAsync_UsageDataAvailable() { SkipIfNotEnabled(); - var response = _chatClient.GetStreamingResponseAsync("Explain in 10 words how AI works", new() + var response = ChatClient.GetStreamingResponseAsync("Explain in 10 words how AI works", new() { AdditionalProperties = new() { @@ -160,7 +160,7 @@ public virtual async Task GetStreamingResponseAsync_AppendToHistory() List history = [new(ChatRole.User, "Explain in 100 words how AI works")]; - var streamingResponse = _chatClient.GetStreamingResponseAsync(history); + var streamingResponse = ChatClient.GetStreamingResponseAsync(history); Assert.Single(history); await history.AddMessagesAsync(streamingResponse); @@ -179,7 +179,7 @@ public virtual async Task MultiModal_DescribeImage() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.User, [ @@ -197,7 +197,7 @@ public virtual async Task MultiModal_DescribePdf() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.User, [ @@ -223,7 +223,7 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_Paramet .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int secretNumber = 42; @@ -246,7 +246,7 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_WithPar { SkipIfNotEnabled(); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = await chatClient.GetResponseAsync("What is the result of SecretComputation on 42 and 84?", new() { @@ -261,7 +261,7 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_WithPar { SkipIfNotEnabled(); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = chatClient.GetStreamingResponseAsync("What is the result of SecretComputation on 42 and 84?", new() { @@ -290,7 +290,7 @@ public virtual async Task FunctionInvocation_OptionalParameter() .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int secretNumber = 42; @@ -322,7 +322,7 @@ public virtual async Task FunctionInvocation_NestedParameters() .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int secretNumber = 42; @@ -354,7 +354,7 @@ public virtual async Task FunctionInvocation_ArrayParameter() .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); List messages = [ @@ -411,7 +411,7 @@ private async Task AvailableTools_SchemasAreAccepted(bool strict) .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int methodCount = 1; Func createOptions = () => @@ -567,7 +567,7 @@ public virtual async Task FunctionInvocation_SupportsMultipleParallelRequests() throw new SkipTestException("Parallel function calling is not supported by this chat client"); } - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); // The service/model isn't guaranteed to request two calls to GetPersonAge in the same turn, but it's common that it will. var response = await chatClient.GetResponseAsync("How much older is Elsa than Anna? Return the age difference as a single number.", new() @@ -600,7 +600,7 @@ public virtual async Task FunctionInvocation_RequireAny() return 123; }, "GetSecretNumber"); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = await chatClient.GetResponseAsync("Are birds real?", new() { @@ -620,7 +620,7 @@ public virtual async Task FunctionInvocation_RequireSpecific() var getSecretNumberTool = AIFunctionFactory.Create(() => 123, "GetSecretNumber"); var shieldsUpTool = AIFunctionFactory.Create(() => shieldsUp = true, "ShieldsUp"); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); // Even though the user doesn't ask for the shields to be activated, verify that the tool is invoked var response = await chatClient.GetResponseAsync("What's the current secret number?", new() @@ -638,9 +638,9 @@ public virtual async Task Caching_OutputVariesWithoutCaching() SkipIfNotEnabled(); var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); - var firstResponse = await _chatClient.GetResponseAsync([message]); + var firstResponse = await ChatClient.GetResponseAsync([message]); - var secondResponse = await _chatClient.GetResponseAsync([message]); + var secondResponse = await ChatClient.GetResponseAsync([message]); Assert.NotEqual(firstResponse.Text, secondResponse.Text); } @@ -650,7 +650,7 @@ public virtual async Task Caching_SamePromptResultsInCacheHit_NonStreaming() SkipIfNotEnabled(); using var chatClient = new DistributedCachingChatClient( - _chatClient, + ChatClient, new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))); var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); @@ -675,7 +675,7 @@ public virtual async Task Caching_SamePromptResultsInCacheHit_Streaming() SkipIfNotEnabled(); using var chatClient = new DistributedCachingChatClient( - _chatClient, + ChatClient, new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))); var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); @@ -957,7 +957,7 @@ public virtual async Task GetResponseAsync_StructuredOutput() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Who is described in the following sentence? Jimbo Smith is a 35-year-old programmer from Cardiff, Wales. """); @@ -973,7 +973,7 @@ public virtual async Task GetResponseAsync_StructuredOutputArray() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Who are described in the following sentence? Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. Josh Simpson is a 25-year-old software developer from Newport, Wales. @@ -989,7 +989,7 @@ public virtual async Task GetResponseAsync_StructuredOutputInteger() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" There were 14 abstractions for AI programming, which was too many. To fix this we added another one. How many are there now? """); @@ -1002,7 +1002,7 @@ public virtual async Task GetResponseAsync_StructuredOutputString() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" The software developer, Jimbo Smith, is a 35-year-old from Cardiff, Wales. What's his full name? """); @@ -1015,7 +1015,7 @@ public virtual async Task GetResponseAsync_StructuredOutputBool_True() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. Is there at least one software developer from Cardiff? """); @@ -1028,7 +1028,7 @@ public virtual async Task GetResponseAsync_StructuredOutputBool_False() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. Reply true if the previous statement indicates that he is a medical doctor, otherwise false. """); @@ -1041,7 +1041,7 @@ public virtual async Task GetResponseAsync_StructuredOutputEnum() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Taylor Swift is a famous singer and songwriter. What is her job? """); @@ -1061,7 +1061,7 @@ public virtual async Task GetResponseAsync_StructuredOutput_WithFunctions() Job = JobType.Programmer, }; - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = await chatClient.GetResponseAsync( "Who is person with ID 123?", new ChatOptions { @@ -1085,7 +1085,7 @@ public virtual async Task GetResponseAsync_StructuredOutput_NonNative() SkipIfNotEnabled(); var capturedOptions = new List(); - var captureOutputChatClient = _chatClient.AsBuilder() + var captureOutputChatClient = ChatClient.AsBuilder() .Use((messages, options, nextAsync, cancellationToken) => { capturedOptions.Add(options); @@ -1125,12 +1125,12 @@ private enum JobType Unknown, } - [MemberNotNull(nameof(_chatClient))] + [MemberNotNull(nameof(ChatClient))] protected void SkipIfNotEnabled() { string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"]; - if (skipIntegration is not null || _chatClient is null) + if (skipIntegration is not null || ChatClient is null) { throw new SkipTestException("Client is not enabled."); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs index 9d8f806ca8a..a794460a9bd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs @@ -18,7 +18,7 @@ internal static class IntegrationTestHelpers { var configuration = TestRunnerConfiguration.Instance; - string? apiKey = configuration["OpenAI:Key"]; + string? apiKey = configuration["AI:OpenAI:ApiKey"]; string? mode = configuration["OpenAI:Mode"]; if (string.Equals(mode, "AzureOpenAI", StringComparison.OrdinalIgnoreCase)) diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index f8e835bdb81..630af9e34b0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -1,7 +1,11 @@ // 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.Linq; using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; namespace Microsoft.Extensions.AI; @@ -16,4 +20,33 @@ public class OpenAIResponseClientIntegrationTests : ChatClientIntegrationTests // Test structure doesn't make sense with Respones. public override Task Caching_AfterFunctionInvocation_FunctionOutputUnchangedAsync() => Task.CompletedTask; + + [ConditionalFact] + public async Task UseWebSearch_AnnotationsReflectResults() + { + SkipIfNotEnabled(); + + var response = await ChatClient.GetResponseAsync( + "Write a paragraph about the three most recent blog posts on the .NET blog. Cite your sources.", + new() { Tools = [new HostedWebSearchTool()] }); + + ChatMessage m = Assert.Single(response.Messages); + TextContent tc = m.Contents.OfType().First(); + Assert.NotNull(tc.Annotations); + Assert.NotEmpty(tc.Annotations); + Assert.All(tc.Annotations, a => + { + CitationAnnotation ca = Assert.IsType(a); + var regions = Assert.IsType>(ca.AnnotatedRegions); + Assert.NotNull(regions); + Assert.Single(regions); + var region = Assert.IsType(regions[0]); + Assert.NotNull(region); + Assert.NotNull(region.StartIndex); + Assert.NotNull(region.EndIndex); + Assert.NotNull(ca.Url); + Assert.NotNull(ca.Title); + Assert.NotEmpty(ca.Title); + }); + } } From cb570950446b792c330c0dbb27edc23ab828d34f Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 29 Jul 2025 11:04:19 -0400 Subject: [PATCH 50/71] Add FunctionInvokingChatClient.AdditionalTools (#6661) * Add FunctionInvokingChatClient.AdditionalTools> --- .../FunctionInvokingChatClient.cs | 52 ++++++++++--- .../Microsoft.Extensions.AI.json | 4 + .../FunctionInvokingChatClientTests.cs | 77 ++++++++++++++++++- .../Microsoft.Extensions.AI.Tests.csproj | 2 +- 4 files changed, 123 insertions(+), 12 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 0a8673dc91d..9c0506a2307 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -205,6 +205,16 @@ public int MaximumConsecutiveErrorsPerRequest set => _maximumConsecutiveErrorsPerRequest = Throw.IfLessThan(value, 0); } + /// Gets or sets a collection of additional tools the client is able to invoke. + /// + /// These will not impact the requests sent by the , which will pass through the + /// unmodified. However, if the inner client requests the invocation of a tool + /// that was not in , this collection will also be consulted + /// to look for a corresponding tool to invoke. This is useful when the service may have been pre-configured to be aware + /// of certain tools that aren't also sent on each individual request. + /// + public IList? AdditionalTools { get; set; } + /// Gets or sets a delegate used to invoke instances. /// /// By default, the protected method is called for each to be invoked, @@ -250,7 +260,7 @@ public override async Task GetResponseAsync( // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. bool requiresFunctionInvocation = - options?.Tools is { Count: > 0 } && + (options?.Tools is { Count: > 0 } || AdditionalTools is { Count: > 0 }) && iteration < MaximumIterationsPerRequest && CopyFunctionCalls(response.Messages, ref functionCallContents); @@ -288,7 +298,7 @@ public override async Task GetResponseAsync( // Add the responses from the function calls into the augmented history and also into the tracked // list of response messages. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; @@ -297,7 +307,7 @@ public override async Task GetResponseAsync( break; } - UpdateOptionsForNextIteration(ref options!, response.ConversationId); + UpdateOptionsForNextIteration(ref options, response.ConversationId); } Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); @@ -367,7 +377,7 @@ public override async IAsyncEnumerable GetStreamingResponseA // If there are no tools to call, or for any other reason we should stop, return the response. if (functionCallContents is not { Count: > 0 } || - options?.Tools is not { Count: > 0 } || + (options?.Tools is not { Count: > 0 } && AdditionalTools is not { Count: > 0 }) || iteration >= _maximumIterationsPerRequest) { break; @@ -535,9 +545,16 @@ private static bool CopyFunctionCalls( return any; } - private static void UpdateOptionsForNextIteration(ref ChatOptions options, string? conversationId) + private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId) { - if (options.ToolMode is RequiredChatToolMode) + if (options is null) + { + if (conversationId is not null) + { + options = new() { ConversationId = conversationId }; + } + } + else if (options.ToolMode is RequiredChatToolMode) { // We have to reset the tool mode to be non-required after the first iteration, // as otherwise we'll be in an infinite loop. @@ -566,7 +583,7 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin /// The to monitor for cancellation requests. /// A value indicating how the caller should proceed. private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, + List messages, ChatOptions? options, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) { // We must add a response for every tool call, regardless of whether we successfully executed it or not. @@ -695,13 +712,13 @@ private void ThrowIfNoFunctionResultsAdded(IList? messages) /// The to monitor for cancellation requests. /// A value indicating how the caller should proceed. private async Task ProcessFunctionCallAsync( - List messages, ChatOptions options, List callContents, + List messages, ChatOptions? options, List callContents, int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) { var callContent = callContents[functionCallIndex]; // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. - AIFunction? aiFunction = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); + AIFunction? aiFunction = FindAIFunction(options?.Tools, callContent.Name) ?? FindAIFunction(AdditionalTools, callContent.Name); if (aiFunction is null) { return new(terminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); @@ -746,6 +763,23 @@ private async Task ProcessFunctionCallAsync( callContent, result, exception: null); + + static AIFunction? FindAIFunction(IList? tools, string functionName) + { + if (tools is not null) + { + int count = tools.Count; + for (int i = 0; i < count; i++) + { + if (tools[i] is AIFunction function && function.Name == functionName) + { + return function; + } + } + } + + return null; + } } /// Creates one or more response messages for function invocation results. diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json index 59ed3d32fab..3e3f0426dd1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json @@ -515,6 +515,10 @@ } ], "Properties": [ + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.FunctionInvokingChatClient.AdditionalTools { get; set; }", + "Stage": "Stable" + }, { "Member": "bool Microsoft.Extensions.AI.FunctionInvokingChatClient.AllowConcurrentInvocation { get; set; }", "Stage": "Stable" diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index b4ce2f1546c..08cb5ee5760 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -39,6 +39,7 @@ public void Ctor_HasExpectedDefaults() Assert.Equal(40, client.MaximumIterationsPerRequest); Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); Assert.Null(client.FunctionInvoker); + Assert.Null(client.AdditionalTools); } [Fact] @@ -67,6 +68,11 @@ public void Properties_Roundtrip() Func> invoker = (ctx, ct) => new ValueTask("test"); client.FunctionInvoker = invoker; Assert.Same(invoker, client.FunctionInvoker); + + Assert.Null(client.AdditionalTools); + IList additionalTools = [AIFunctionFactory.Create(() => "Additional Tool")]; + client.AdditionalTools = additionalTools; + Assert.Same(additionalTools, client.AdditionalTools); } [Fact] @@ -99,6 +105,73 @@ public async Task SupportsSingleFunctionCallPerRequestAsync() await InvokeAndAssertStreamingAsync(options, plan); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SupportsToolsProvidedByAdditionalTools(bool provideOptions) + { + ChatOptions? options = provideOptions ? + new() { Tools = [AIFunctionFactory.Create(() => "Shouldn't be invoked", "ChatOptionsFunc")] } : + null; + + Func configure = builder => + builder.UseFunctionInvocation(configure: c => c.AdditionalTools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ]); + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); + } + + [Fact] + public async Task PrefersToolsProvidedByChatOptions() + { + ChatOptions options = new() + { + Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")] + }; + + Func configure = builder => + builder.UseFunctionInvocation(configure: c => c.AdditionalTools = + [ + AIFunctionFactory.Create(() => "Should never be invoked", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ]); + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -1002,7 +1075,7 @@ public override void Post(SendOrPostCallback d, object? state) } private static async Task> InvokeAndAssertAsync( - ChatOptions options, + ChatOptions? options, List plan, List? expected = null, Func? configurePipeline = null, @@ -1102,7 +1175,7 @@ private static UsageDetails CreateRandomUsage() } private static async Task> InvokeAndAssertStreamingAsync( - ChatOptions options, + ChatOptions? options, List plan, List? expected = null, Func? configurePipeline = null, 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 e4f17abb179..c07f3056054 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 @@ -5,7 +5,7 @@ - $(NoWarn);CA1063;CA1861;SA1130;VSTHRD003 + $(NoWarn);CA1063;CA1861;S104;SA1130;VSTHRD003 $(NoWarn);MEAI001 true From edf7a946f3d77f50b30cc4a8496793adef460f65 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 29 Jul 2025 11:04:35 -0400 Subject: [PATCH 51/71] Fix unintentional test env var change (#6660) --- .../IntegrationTestHelpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs index a794460a9bd..9d8f806ca8a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs @@ -18,7 +18,7 @@ internal static class IntegrationTestHelpers { var configuration = TestRunnerConfiguration.Instance; - string? apiKey = configuration["AI:OpenAI:ApiKey"]; + string? apiKey = configuration["OpenAI:Key"]; string? mode = configuration["OpenAI:Mode"]; if (string.Equals(mode, "AzureOpenAI", StringComparison.OrdinalIgnoreCase)) From 65c679b342662c23f707a92a775eab38dfa7fff1 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 30 Jul 2025 23:01:52 -0400 Subject: [PATCH 52/71] Add more OpenAI conversion helpers (#6662) --- .../MicrosoftExtensionsAIChatExtensions.cs | 175 +++++++++++++ ...icrosoftExtensionsAIResponsesExtensions.cs | 24 ++ .../OpenAIAssistantsChatClient.cs | 24 +- .../OpenAIChatClient.cs | 59 +++-- .../OpenAIClientExtensions.cs | 22 ++ .../OpenAIResponsesChatClient.cs | 126 ++++----- .../OpenAIConversionTests.cs | 241 +++++++++++++++++- 7 files changed, 573 insertions(+), 98 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs index c7eb3b2e03e..13242a9b32f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs @@ -2,10 +2,17 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ClientModel.Primitives; using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Encodings.Web; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; +#pragma warning disable S103 // Lines should not be too long + namespace OpenAI.Chat; /// Provides extension methods for working with content associated with OpenAI.Chat. @@ -21,13 +28,181 @@ public static ChatTool AsOpenAIChatTool(this AIFunction function) => /// Creates a sequence of OpenAI instances from the specified input messages. /// The input messages to convert. /// A sequence of OpenAI chat messages. + /// is . public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages) => OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null); + /// Creates an OpenAI from a . + /// The to convert to a . + /// A converted . + /// is . + public static ChatCompletion AsOpenAIChatCompletion(this ChatResponse response) + { + _ = Throw.IfNull(response); + + if (response.RawRepresentation is ChatCompletion chatCompletion) + { + return chatCompletion; + } + + var lastMessage = response.Messages.LastOrDefault(); + + ChatMessageRole role = lastMessage?.Role.Value switch + { + "user" => ChatMessageRole.User, + "function" => ChatMessageRole.Function, + "tool" => ChatMessageRole.Tool, + "developer" => ChatMessageRole.Developer, + "system" => ChatMessageRole.System, + _ => ChatMessageRole.Assistant, + }; + + ChatFinishReason finishReason = response.FinishReason?.Value switch + { + "length" => ChatFinishReason.Length, + "content_filter" => ChatFinishReason.ContentFilter, + "tool_calls" => ChatFinishReason.ToolCalls, + "function_call" => ChatFinishReason.FunctionCall, + _ => ChatFinishReason.Stop, + }; + + ChatTokenUsage usage = OpenAIChatModelFactory.ChatTokenUsage( + (int?)response.Usage?.OutputTokenCount ?? 0, + (int?)response.Usage?.InputTokenCount ?? 0, + (int?)response.Usage?.TotalTokenCount ?? 0); + + IEnumerable? toolCalls = lastMessage?.Contents + .OfType().Select(c => ChatToolCall.CreateFunctionToolCall(c.CallId, c.Name, + new BinaryData(JsonSerializer.SerializeToUtf8Bytes(c.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))); + + return OpenAIChatModelFactory.ChatCompletion( + response.ResponseId, + finishReason, + new(OpenAIChatClient.ToOpenAIChatContent(lastMessage?.Contents ?? [])), + toolCalls: toolCalls, + role: role, + createdAt: response.CreatedAt ?? default, + model: response.ModelId, + usage: usage, + outputAudio: lastMessage?.Contents.OfType().Where(dc => dc.HasTopLevelMediaType("audio")).Select(a => OpenAIChatModelFactory.ChatOutputAudio(new(a.Data))).FirstOrDefault(), + messageAnnotations: ConvertAnnotations(lastMessage?.Contents)); + + static IEnumerable ConvertAnnotations(IEnumerable? contents) + { + if (contents is null) + { + yield break; + } + + foreach (var content in contents) + { + if (content.Annotations is null) + { + continue; + } + + foreach (var annotation in content.Annotations) + { + if (annotation is not CitationAnnotation citation) + { + continue; + } + + if (citation.AnnotatedRegions?.OfType().ToArray() is { Length: > 0 } regions) + { + foreach (var region in regions) + { + yield return OpenAIChatModelFactory.ChatMessageAnnotation(region.StartIndex ?? 0, region.EndIndex ?? 0, citation.Url, citation.Title); + } + } + else + { + yield return OpenAIChatModelFactory.ChatMessageAnnotation(0, 0, citation.Url, citation.Title); + } + } + } + } + } + + /// Creates a sequence of instances from the specified input messages. + /// The input messages to convert. + /// A sequence of Microsoft.Extensions.AI chat messages. + /// is . + public static IEnumerable AsChatMessages(this IEnumerable messages) + { + _ = Throw.IfNull(messages); + + foreach (var message in messages) + { + Microsoft.Extensions.AI.ChatMessage resultMessage = new() + { + RawRepresentation = message, + }; + + switch (message) + { + case AssistantChatMessage acm: + resultMessage.AuthorName = acm.ParticipantName; + OpenAIChatClient.ConvertContentParts(acm.Content, resultMessage.Contents); + foreach (var toolCall in acm.ToolCalls) + { + var fcc = OpenAIClientExtensions.ParseCallContent(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + fcc.RawRepresentation = toolCall; + resultMessage.Contents.Add(fcc); + } + + break; + + case UserChatMessage ucm: + resultMessage.AuthorName = ucm.ParticipantName; + OpenAIChatClient.ConvertContentParts(ucm.Content, resultMessage.Contents); + break; + + case DeveloperChatMessage dcm: + resultMessage.AuthorName = dcm.ParticipantName; + OpenAIChatClient.ConvertContentParts(dcm.Content, resultMessage.Contents); + break; + + case SystemChatMessage scm: + resultMessage.AuthorName = scm.ParticipantName; + OpenAIChatClient.ConvertContentParts(scm.Content, resultMessage.Contents); + break; + + case ToolChatMessage tcm: + resultMessage.Contents.Add(new FunctionResultContent(tcm.ToolCallId, ToToolResult(tcm.Content)) + { + RawRepresentation = tcm, + }); + + static object ToToolResult(ChatMessageContent content) + { + if (content.Count == 1 && content[0] is { Text: { } text }) + { + return text; + } + + MemoryStream ms = new(); + using Utf8JsonWriter writer = new(ms, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + foreach (IJsonModel part in content) + { + part.Write(writer, ModelReaderWriterOptions.Json); + } + + return JsonSerializer.Deserialize(ms.GetBuffer().AsSpan(0, (int)ms.Position), AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!; + } + + break; + } + + yield return resultMessage; + } + } + /// Creates a Microsoft.Extensions.AI from a . /// The to convert to a . /// The options employed in the creation of the response. /// A converted . + /// is . public static ChatResponse AsChatResponse(this ChatCompletion chatCompletion, ChatCompletionOptions? options = null) => OpenAIChatClient.FromOpenAIChatCompletion(Throw.IfNull(chatCompletion), options); } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index e54b3092c5a..8f39ad7852e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -21,13 +21,37 @@ public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => /// Creates a sequence of OpenAI instances from the specified input messages. /// The input messages to convert. /// A sequence of OpenAI response items. + /// is . public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages) => OpenAIResponsesChatClient.ToOpenAIResponseItems(Throw.IfNull(messages)); + /// Creates a sequence of instances from the specified input items. + /// The input messages to convert. + /// A sequence of instances. + /// is . + public static IEnumerable AsChatMessages(this IEnumerable items) => + OpenAIResponsesChatClient.ToChatMessages(Throw.IfNull(items)); + /// Creates a Microsoft.Extensions.AI from an . /// The to convert to a . /// The options employed in the creation of the response. /// A converted . + /// is . public static ChatResponse AsChatResponse(this OpenAIResponse response, ResponseCreationOptions? options = null) => OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options); + + /// Creates an OpenAI from a . + /// The response to convert. + /// The created . + internal static OpenAIResponse AsOpenAIResponse(this ChatResponse response) // Implement and make public once OpenAIResponse can be constructed external to the OpenAI library. + { + _ = Throw.IfNull(response); + + if (response.RawRepresentation is OpenAIResponse openAIResponse) + { + return openAIResponse; + } + + throw new NotSupportedException(); + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs index c437a553d28..bca2802460c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs @@ -199,11 +199,12 @@ public async IAsyncEnumerable GetStreamingResponseAsync( if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName) { - ruUpdate.Contents.Add( - new FunctionCallContent( - JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray), - functionName, - JsonSerializer.Deserialize(rau.FunctionArguments, OpenAIJsonContext.Default.IDictionaryStringObject)!)); + var fcc = OpenAIClientExtensions.ParseCallContent( + rau.FunctionArguments, + JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray), + functionName); + fcc.RawRepresentation = ru; + ruUpdate.Contents.Add(fcc); } yield return ruUpdate; @@ -440,6 +441,10 @@ void AppendSystemInstructions(string? toAppend) { switch (content) { + case AIContent when content.RawRepresentation is MessageContent rawRep: + messageContents.Add(rawRep); + break; + case TextContent text: messageContents.Add(MessageContent.FromText(text.Text)); break; @@ -448,18 +453,9 @@ void AppendSystemInstructions(string? toAppend) messageContents.Add(MessageContent.FromImageUri(image.Uri)); break; - // Assistants doesn't support data URIs. - //case DataContent image when image.HasTopLevelMediaType("image"): - // messageContents.Add(MessageContent.FromImageUri(new Uri(image.Uri))); - // break; - case FunctionResultContent result: (functionResults ??= []).Add(result); break; - - case AIContent when content.RawRepresentation is MessageContent rawRep: - messageContents.Add(rawRep); - break; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 8a4e08cac9f..7173046ccac 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -129,6 +129,12 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op foreach (ChatMessage input in inputs) { + if (input.RawRepresentation is OpenAI.Chat.ChatMessage raw) + { + yield return raw; + continue; + } + if (input.Role == ChatRole.System || input.Role == ChatRole.User || input.Role == OpenAIClientExtensions.ChatRoleDeveloper) @@ -219,15 +225,22 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op } /// Converts a list of to a list of . - private static List ToOpenAIChatContent(IList contents) + internal static List ToOpenAIChatContent(IEnumerable contents) { List parts = []; foreach (var content in contents) { - if (ToChatMessageContentPart(content) is { } part) + if (content.RawRepresentation is ChatMessageContentPart raw) { - parts.Add(part); + parts.Add(raw); + } + else + { + if (ToChatMessageContentPart(content) is { } part) + { + parts.Add(part); + } } } @@ -243,6 +256,9 @@ private static List ToOpenAIChatContent(IList { switch (content) { + case AIContent when content.RawRepresentation is ChatMessageContentPart rawContentPart: + return rawContentPart; + case TextContent textContent: return ChatMessageContentPart.CreateTextPart(textContent.Text); @@ -267,9 +283,6 @@ private static List ToOpenAIChatContent(IList case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): return ChatMessageContentPart.CreateFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf"); - - case AIContent when content.RawRepresentation is ChatMessageContentPart rawContentPart: - return rawContentPart; } return null; @@ -328,13 +341,7 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha // Transfer over content update items. if (update.ContentUpdate is { Count: > 0 }) { - foreach (ChatMessageContentPart contentPart in update.ContentUpdate) - { - if (ToAIContent(contentPart) is AIContent aiContent) - { - responseUpdate.Contents.Add(aiContent); - } - } + ConvertContentParts(update.ContentUpdate, responseUpdate.Contents); } if (update.OutputAudioUpdate is { } audioUpdate) @@ -402,7 +409,7 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha FunctionCallInfo fci = entry.Value; if (!string.IsNullOrWhiteSpace(fci.Name)) { - var callContent = ParseCallContentFromJsonString( + var callContent = OpenAIClientExtensions.ParseCallContent( fci.Arguments?.ToString() ?? string.Empty, fci.CallId!, fci.Name!); @@ -468,7 +475,7 @@ internal static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompl { if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) { - var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + var callContent = OpenAIClientExtensions.ParseCallContent(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); callContent.RawRepresentation = toolCall; returnMessage.Contents.Add(callContent); @@ -648,6 +655,20 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => _ => new ChatRole(role.ToString()), }; + /// Creates s from . + /// The content parts to convert into a content. + /// The result collection into which to write the resulting content. + internal static void ConvertContentParts(ChatMessageContent content, IList results) + { + foreach (ChatMessageContentPart contentPart in content) + { + if (ToAIContent(contentPart) is { } aiContent) + { + results.Add(aiContent); + } + } + } + /// Creates an from a . /// The content part to convert into a content. /// The constructed , or if the content part could not be converted. @@ -697,14 +718,6 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => _ => new ChatFinishReason(s), }; - private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); - - private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); - /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. private sealed class FunctionCallInfo { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 889998cc933..3881f246d98 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -184,6 +184,28 @@ internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, boo return functionParameters; } + /// Creates a new instance of parsing arguments using a specified encoding and parser. + /// The input arguments to be parsed. + /// The function call ID. + /// The function name. + /// A new instance of containing the parse result. + /// is . + /// is . + internal static FunctionCallContent ParseCallContent(string json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(json, callId, name, + static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); + + /// Creates a new instance of parsing arguments using a specified encoding and parser. + /// The input arguments to be parsed. + /// The function call ID. + /// The function name. + /// A new instance of containing the parse result. + /// is . + /// is . + internal static FunctionCallContent ParseCallContent(BinaryData utf8json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(utf8json, callId, name, + static utf8json => JsonSerializer.Deserialize(utf8json, OpenAIJsonContext.Default.IDictionaryStringObject)!); + /// Used to create the JSON payload for an OpenAI tool description. internal sealed class ToolJson { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 5bd3529e292..40e2ee048fd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -90,7 +90,6 @@ internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, R ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : openAIResponse.Id, CreatedAt = openAIResponse.CreatedAt, FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), - Messages = [new(ChatRole.Assistant, [])], ModelId = openAIResponse.Model, RawRepresentation = openAIResponse, ResponseId = openAIResponse.Id, @@ -109,65 +108,69 @@ internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, R if (openAIResponse.OutputItems is not null) { - ChatMessage message = response.Messages[0]; - Debug.Assert(message.Contents is List, "Expected a List for message contents."); + response.Messages = [.. ToChatMessages(openAIResponse.OutputItems)]; - foreach (ResponseItem outputItem in openAIResponse.OutputItems) + if (response.Messages.LastOrDefault() is { } lastMessage && openAIResponse.Error is { } error) { - switch (outputItem) - { - case MessageResponseItem messageItem: - if (message.MessageId is not null && message.MessageId != messageItem.Id) - { - message = new ChatMessage(); - response.Messages.Add(message); - } + lastMessage.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); + } - message.MessageId = messageItem.Id; - message.RawRepresentation = messageItem; - message.Role = ToChatRole(messageItem.Role); - ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); - break; + foreach (var message in response.Messages) + { + message.CreatedAt ??= openAIResponse.CreatedAt; + } + } - case ReasoningResponseItem reasoningItem when reasoningItem.GetSummaryText() is string summary && !string.IsNullOrWhiteSpace(summary): - message.Contents.Add(new TextReasoningContent(summary) - { - RawRepresentation = reasoningItem - }); - break; + return response; + } - case FunctionCallResponseItem functionCall: - response.FinishReason ??= ChatFinishReason.ToolCalls; - var fcc = FunctionCallContent.CreateFromParsedArguments( - functionCall.FunctionArguments.ToMemory(), - functionCall.CallId, - functionCall.FunctionName, - static json => JsonSerializer.Deserialize(json.Span, OpenAIJsonContext.Default.IDictionaryStringObject)!); - fcc.RawRepresentation = outputItem; - message.Contents.Add(fcc); - break; + internal static IEnumerable ToChatMessages(IEnumerable items) + { + ChatMessage? message = null; - default: - message.Contents.Add(new() - { - RawRepresentation = outputItem, - }); - break; - } - } + foreach (ResponseItem outputItem in items) + { + message ??= new(ChatRole.Assistant, (string?)null); - if (openAIResponse.Error is { } error) + switch (outputItem) { - message.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); + case MessageResponseItem messageItem: + if (message.MessageId is not null && message.MessageId != messageItem.Id) + { + yield return message; + message = new ChatMessage(); + } + + message.MessageId = messageItem.Id; + message.RawRepresentation = messageItem; + message.Role = ToChatRole(messageItem.Role); + ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); + break; + + case ReasoningResponseItem reasoningItem when reasoningItem.GetSummaryText() is string summary: + message.Contents.Add(new TextReasoningContent(summary) { RawRepresentation = reasoningItem }); + break; + + case FunctionCallResponseItem functionCall: + var fcc = OpenAIClientExtensions.ParseCallContent(functionCall.FunctionArguments, functionCall.CallId, functionCall.FunctionName); + fcc.RawRepresentation = outputItem; + message.Contents.Add(fcc); + break; + + case FunctionCallOutputResponseItem functionCallOutputItem: + message.Contents.Add(new FunctionResultContent(functionCallOutputItem.CallId, functionCallOutputItem.FunctionOutput) { RawRepresentation = functionCallOutputItem }); + break; + + default: + message.Contents.Add(new() { RawRepresentation = outputItem }); + break; } } - foreach (var message in response.Messages) + if (message is not null) { - message.CreatedAt = openAIResponse.CreatedAt; + yield return message; } - - return response; } /// @@ -266,15 +269,14 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { _ = functionCallInfos.Remove(functionCallOutputDoneUpdate.OutputIndex); - var fci = FunctionCallContent.CreateFromParsedArguments( + var fcc = OpenAIClientExtensions.ParseCallContent( callInfo.Arguments?.ToString() ?? string.Empty, callInfo.ResponseItem.CallId, - callInfo.ResponseItem.FunctionName, - static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); + callInfo.ResponseItem.FunctionName); lastMessageId = callInfo.ResponseItem.Id; lastRole = ChatRole.Assistant; - yield return new ChatResponseUpdate(lastRole, [fci]) + yield return new ChatResponseUpdate(lastRole, [fcc]) { ConversationId = conversationId, CreatedAt = createdAt, @@ -513,6 +515,10 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable ToOpenAIResponseItems(IEnumerable ToOpenAIResponseItems(IEnumerable))))); break; - - case AIContent when item.RawRepresentation is ResponseItem rawRep: - yield return rawRep; - break; } } @@ -659,6 +665,10 @@ private static List ToOpenAIResponsesContent(IList ToOpenAIResponsesContent(IList(message.Contents[1]).Uri.ToString()); Assert.Equal("functionName", Assert.IsType(message.Contents[2]).Name); } + + [Fact] + public void AsChatMessages_FromOpenAIChatMessages_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsChatMessages().ToArray()); + + List openAIMessages = + [ + new SystemChatMessage("You are a helpful assistant."), + new UserChatMessage("Hello"), + new AssistantChatMessage(ChatMessageContentPart.CreateTextPart("Hi there!")), + new ToolChatMessage("call456", "Function output") + ]; + + var convertedMessages = openAIMessages.AsChatMessages().ToArray(); + + Assert.Equal(4, convertedMessages.Length); + + Assert.Equal("You are a helpful assistant.", convertedMessages[0].Text); + Assert.Equal("Hello", convertedMessages[1].Text); + Assert.Equal("Hi there!", convertedMessages[2].Text); + Assert.Equal("Function output", convertedMessages[3].Contents.OfType().First().Result); + } + + [Fact] + public void AsChatMessages_FromResponseItems_WithNullArgument_ThrowsArgumentNullException() + { + Assert.Throws("items", () => ((IEnumerable)null!).AsChatMessages()); + } + + [Fact] + public void AsChatMessages_FromResponseItems_ProducesExpectedOutput() + { + List inputMessages = + [ + new(ChatRole.Assistant, "Hi there!") + ]; + + var responseItems = inputMessages.AsOpenAIResponseItems().ToArray(); + + var convertedMessages = responseItems.AsChatMessages().ToArray(); + + Assert.Single(convertedMessages); + + var message = convertedMessages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + Assert.Equal("Hi there!", message.Text); + } + + [Fact] + public void AsChatMessages_FromResponseItems_WithEmptyCollection_ReturnsEmptyCollection() + { + var convertedMessages = Array.Empty().AsChatMessages().ToArray(); + Assert.Empty(convertedMessages); + } + + [Fact] + public void AsChatMessages_FromResponseItems_WithFunctionCall_HandlesCorrectly() + { + List inputMessages = + [ + new(ChatRole.Assistant, + [ + new TextContent("I'll call a function."), + new FunctionCallContent("call123", "TestFunction", new Dictionary { ["param"] = "value" }) + ]) + ]; + + var responseItems = inputMessages.AsOpenAIResponseItems().ToArray(); + var convertedMessages = responseItems.AsChatMessages().ToArray(); + + Assert.Single(convertedMessages); + + var message = convertedMessages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + + var textContent = message.Contents.OfType().FirstOrDefault(); + var functionCall = message.Contents.OfType().FirstOrDefault(); + + Assert.NotNull(textContent); + Assert.Equal("I'll call a function.", textContent.Text); + + Assert.NotNull(functionCall); + Assert.Equal("call123", functionCall.CallId); + Assert.Equal("TestFunction", functionCall.Name); + Assert.Equal("value", functionCall.Arguments!["param"]?.ToString()); + } + + [Fact] + public void AsOpenAIChatCompletion_WithNullArgument_ThrowsArgumentNullException() + { + Assert.Throws("response", () => ((ChatResponse)null!).AsOpenAIChatCompletion()); + } + + [Fact] + public void AsOpenAIChatCompletion_WithMultipleContents_ProducesValidInstance() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new TextContent("Here's an image and some text."), + new UriContent("https://example.com/image.jpg", "image/jpeg"), + new DataContent(new byte[] { 1, 2, 3, 4 }, "application/octet-stream") + ])) + { + ResponseId = "multi-content-response", + ModelId = "gpt-4-vision", + FinishReason = ChatFinishReason.Stop, + CreatedAt = new DateTimeOffset(2025, 1, 3, 14, 30, 0, TimeSpan.Zero), + Usage = new UsageDetails + { + InputTokenCount = 25, + OutputTokenCount = 12, + TotalTokenCount = 37 + } + }; + + ChatCompletion completion = chatResponse.AsOpenAIChatCompletion(); + + Assert.Equal("multi-content-response", completion.Id); + Assert.Equal("gpt-4-vision", completion.Model); + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, completion.FinishReason); + Assert.Equal(ChatMessageRole.Assistant, completion.Role); + Assert.Equal(new DateTimeOffset(2025, 1, 3, 14, 30, 0, TimeSpan.Zero), completion.CreatedAt); + + Assert.NotNull(completion.Usage); + Assert.Equal(25, completion.Usage.InputTokenCount); + Assert.Equal(12, completion.Usage.OutputTokenCount); + Assert.Equal(37, completion.Usage.TotalTokenCount); + + Assert.NotEmpty(completion.Content); + Assert.Contains(completion.Content, c => c.Text == "Here's an image and some text."); + } + + [Fact] + public void AsOpenAIChatCompletion_WithEmptyData_HandlesGracefully() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")); + var completion = chatResponse.AsOpenAIChatCompletion(); + + Assert.NotNull(completion); + Assert.Equal(ChatMessageRole.Assistant, completion.Role); + Assert.Equal("Hello", Assert.Single(completion.Content).Text); + Assert.Empty(completion.ToolCalls); + + var emptyResponse = new ChatResponse([]); + var emptyCompletion = emptyResponse.AsOpenAIChatCompletion(); + Assert.NotNull(emptyCompletion); + Assert.Equal(ChatMessageRole.Assistant, emptyCompletion.Role); + } + + [Fact] + public void AsOpenAIChatCompletion_WithComplexFunctionCallArguments_SerializesCorrectly() + { + var complexArgs = new Dictionary + { + ["simpleString"] = "hello", + ["number"] = 42, + ["boolean"] = true, + ["nullValue"] = null, + ["nestedObject"] = new Dictionary + { + ["innerString"] = "world", + ["innerArray"] = new[] { 1, 2, 3 } + } + }; + + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new TextContent("I'll process this complex data."), + new FunctionCallContent("process_data", "ProcessComplexData", complexArgs) + ])) + { + ResponseId = "complex-function-call", + ModelId = "gpt-4", + FinishReason = ChatFinishReason.ToolCalls + }; + + ChatCompletion completion = chatResponse.AsOpenAIChatCompletion(); + + Assert.Equal("complex-function-call", completion.Id); + Assert.Equal(OpenAI.Chat.ChatFinishReason.ToolCalls, completion.FinishReason); + + var toolCall = Assert.Single(completion.ToolCalls); + Assert.Equal("process_data", toolCall.Id); + Assert.Equal("ProcessComplexData", toolCall.FunctionName); + + var deserializedArgs = JsonSerializer.Deserialize>(toolCall.FunctionArguments.ToMemory().Span); + Assert.NotNull(deserializedArgs); + Assert.Equal("hello", deserializedArgs["simpleString"]?.ToString()); + Assert.Equal(42, ((JsonElement)deserializedArgs["number"]!).GetInt32()); + Assert.True(((JsonElement)deserializedArgs["boolean"]!).GetBoolean()); + Assert.Null(deserializedArgs["nullValue"]); + + var nestedObj = (JsonElement)deserializedArgs["nestedObject"]!; + Assert.Equal("world", nestedObj.GetProperty("innerString").GetString()); + Assert.Equal(3, nestedObj.GetProperty("innerArray").GetArrayLength()); + } + + [Fact] + public void AsOpenAIChatCompletion_WithDifferentFinishReasons_MapsCorrectly() + { + var testCases = new[] + { + (ChatFinishReason.Stop, OpenAI.Chat.ChatFinishReason.Stop), + (ChatFinishReason.Length, OpenAI.Chat.ChatFinishReason.Length), + (ChatFinishReason.ContentFilter, OpenAI.Chat.ChatFinishReason.ContentFilter), + (ChatFinishReason.ToolCalls, OpenAI.Chat.ChatFinishReason.ToolCalls) + }; + + foreach (var (inputFinishReason, expectedOpenAIFinishReason) in testCases) + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test")) + { + FinishReason = inputFinishReason + }; + + var completion = chatResponse.AsOpenAIChatCompletion(); + Assert.Equal(expectedOpenAIFinishReason, completion.FinishReason); + } + } + + [Fact] + public void AsOpenAIChatCompletion_WithDifferentRoles_MapsCorrectly() + { + var testCases = new[] + { + (ChatRole.Assistant, ChatMessageRole.Assistant), + (ChatRole.User, ChatMessageRole.User), + (ChatRole.System, ChatMessageRole.System), + (ChatRole.Tool, ChatMessageRole.Tool) + }; + + foreach (var (inputRole, expectedOpenAIRole) in testCases) + { + var chatResponse = new ChatResponse(new ChatMessage(inputRole, "Test")); + var completion = chatResponse.AsOpenAIChatCompletion(); + Assert.Equal(expectedOpenAIRole, completion.Role); + } + } } From 55f44e897fa54f3864bfdf5c64c82a0d3687c6bb Mon Sep 17 00:00:00 2001 From: Brennan Date: Wed, 30 Jul 2025 21:07:14 -0700 Subject: [PATCH 53/71] Add OriginalRepoCommitHash to assemblies (#6667) --- Directory.Build.targets | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Directory.Build.targets b/Directory.Build.targets index 5fcf797523c..31b130899b1 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -59,6 +59,13 @@ + + + <_Parameter1>OriginalRepoCommitHash + <_Parameter2>$(RepoOriginalSourceRevisionId) + + + From c9584a40813de2f70b2e8ab9a03048c3b5089422 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 31 Jul 2025 17:26:51 +0300 Subject: [PATCH 54/71] Include a trivial items keyword if missing. (#6669) --- .../Utilities/AIJsonUtilities.Schema.Create.cs | 7 +++++++ .../Utilities/AIJsonUtilitiesTests.cs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index c77e7dffb5b..17e5e4d5353 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -14,6 +14,7 @@ using System.Text.Json.Nodes; using System.Text.Json.Schema; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading; using Microsoft.Shared.Diagnostics; @@ -289,6 +290,12 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js objSchema.InsertAtStart(TypePropertyName, "string"); } + // Include a trivial items keyword if missing + if (ctx.TypeInfo.Kind is JsonTypeInfoKind.Enumerable && !objSchema.ContainsKey(ItemsPropertyName)) + { + objSchema.Add(ItemsPropertyName, new JsonObject()); + } + // Some consumers of the JSON schema, including Ollama as of v0.3.13, don't understand // schemas with "type": [...], and only understand "type" being a single value. // In certain configurations STJ represents .NET numeric types as ["string", "number"], which will then lead to an error. diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index acbb5515085..bcec2981c5b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -170,6 +170,21 @@ public static void CreateJsonSchema_DefaultParameters_GeneratesExpectedJsonSchem AssertDeepEquals(expected, actual); } + [Fact] + public static void CreateJsonSchema_TrivialArray_GeneratesExpectedJsonSchema() + { + JsonElement expected = JsonDocument.Parse(""" + { + "type": "array", + "items": {} + } + """).RootElement; + + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(object[]), serializerOptions: JsonContext.Default.Options); + + AssertDeepEquals(expected, actual); + } + [Fact] public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSchema() { @@ -1326,6 +1341,7 @@ private class DerivedAIContent : AIContent [JsonSerializable(typeof(DerivedAIContent))] [JsonSerializable(typeof(MyPoco))] [JsonSerializable(typeof(MyEnumValue?))] + [JsonSerializable(typeof(object[]))] private partial class JsonContext : JsonSerializerContext; private static bool DeepEquals(JsonElement element1, JsonElement element2) From 9267e19f9cd8de418721947a3cdc08419a5d1a6f Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 31 Jul 2025 20:54:45 +0300 Subject: [PATCH 55/71] Add resolution of function parameter level data annotation attributes. (#6671) * Add resolution of function parameter level data annotation attributes. * Update test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs Co-authored-by: Stephen Toub --------- Co-authored-by: Stephen Toub --- .../AIJsonUtilities.Schema.Create.cs | 53 +++++++++++-------- .../Utilities/AIJsonUtilitiesTests.cs | 20 +++++++ 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index 17e5e4d5353..da0124639de 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -113,7 +113,7 @@ public static JsonElement CreateFunctionJsonSchema( JsonNode parameterSchema = CreateJsonSchemaCore( type: parameter.ParameterType, - parameterName: parameter.Name, + parameter: parameter, description: parameter.GetCustomAttribute(inherit: true)?.Description, hasDefaultValue: parameter.HasDefaultValue, defaultValue: GetDefaultValueNormalized(parameter), @@ -178,7 +178,7 @@ public static JsonElement CreateJsonSchema( { serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; - JsonNode schema = CreateJsonSchemaCore(type, parameterName: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions); + JsonNode schema = CreateJsonSchemaCore(type, parameter: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions); // Finally, apply any schema transformations if specified. if (inferenceOptions.TransformOptions is { } options) @@ -208,7 +208,7 @@ internal static void ValidateSchemaDocument(JsonElement document, [CallerArgumen #endif private static JsonNode CreateJsonSchemaCore( Type? type, - string? parameterName, + ParameterInfo? parameter, string? description, bool hasDefaultValue, object? defaultValue, @@ -272,14 +272,14 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js // The resulting schema might be a $ref using a pointer to a different location in the document. // As JSON pointer doesn't support relative paths, parameter schemas need to fix up such paths // to accommodate the fact that they're being nested inside of a higher-level schema. - if (parameterName is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName)) + if (parameter?.Name is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName)) { // Fix up any $ref URIs to match the path from the root document. string refUri = paramName!.GetValue(); Debug.Assert(refUri is "#" || refUri.StartsWith("#/", StringComparison.Ordinal), $"Expected {nameof(refUri)} to be either # or start with #/, got {refUri}"); refUri = refUri == "#" - ? $"#/{PropertiesPropertyName}/{parameterName}" - : $"#/{PropertiesPropertyName}/{parameterName}/{refUri.AsMemory("#/".Length)}"; + ? $"#/{PropertiesPropertyName}/{parameter.Name}" + : $"#/{PropertiesPropertyName}/{parameter.Name}/{refUri.AsMemory("#/".Length)}"; objSchema[RefPropertyName] = (JsonNode)refUri; } @@ -359,7 +359,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); } - ApplyDataAnnotations(parameterName, ref schema, ctx); + ApplyDataAnnotations(ref schema, ctx); // Finally, apply any user-defined transformations if specified. if (inferenceOptions.TransformSchemaNode is { } transformer) @@ -389,30 +389,30 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) } } - void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSchemaCreateContext ctx) + void ApplyDataAnnotations(ref JsonNode schema, AIJsonSchemaCreateContext ctx) { - if (ctx.GetCustomAttribute() is { } displayNameAttribute) + if (ResolveAttribute() is { } displayNameAttribute) { ConvertSchemaToObject(ref schema)[TitlePropertyName] ??= displayNameAttribute.DisplayName; } #if NET || NETFRAMEWORK - if (ctx.GetCustomAttribute() is { } emailAttribute) + if (ResolveAttribute() is { } emailAttribute) { ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "email"; } - if (ctx.GetCustomAttribute() is { } urlAttribute) + if (ResolveAttribute() is { } urlAttribute) { ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "uri"; } - if (ctx.GetCustomAttribute() is { } regexAttribute) + if (ResolveAttribute() is { } regexAttribute) { ConvertSchemaToObject(ref schema)[PatternPropertyName] ??= regexAttribute.Pattern; } - if (ctx.GetCustomAttribute() is { } stringLengthAttribute) + if (ResolveAttribute() is { } stringLengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -424,7 +424,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche obj[MaxLengthStringPropertyName] ??= stringLengthAttribute.MaximumLength; } - if (ctx.GetCustomAttribute() is { } minLengthAttribute) + if (ResolveAttribute() is { } minLengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") @@ -437,7 +437,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } maxLengthAttribute) + if (ResolveAttribute() is { } maxLengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") @@ -450,7 +450,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } rangeAttribute) + if (ResolveAttribute() is { } rangeAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -521,12 +521,12 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche #endif #if NET - if (ctx.GetCustomAttribute() is { } base64Attribute) + if (ResolveAttribute() is { } base64Attribute) { ConvertSchemaToObject(ref schema)[ContentEncodingPropertyName] ??= "base64"; } - if (ctx.GetCustomAttribute() is { } lengthAttribute) + if (ResolveAttribute() is { } lengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -550,7 +550,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } allowedValuesAttribute) + if (ResolveAttribute() is { } allowedValuesAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); if (!obj.ContainsKey(EnumPropertyName)) @@ -562,7 +562,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } deniedValuesAttribute) + if (ResolveAttribute() is { } deniedValuesAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -597,7 +597,7 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali return enumArray; } - if (ctx.GetCustomAttribute() is { } dataTypeAttribute) + if (ResolveAttribute() is { } dataTypeAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); switch (dataTypeAttribute.DataType) @@ -629,6 +629,17 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali } } #endif + TAttribute? ResolveAttribute() + where TAttribute : Attribute + { + // If this is the root schema, check for any parameter attributes first. + if (ctx.Path.IsEmpty && parameter?.GetCustomAttribute(inherit: true) is TAttribute attr) + { + return attr; + } + + return ctx.GetCustomAttribute(inherit: true); + } } } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index bcec2981c5b..11926e5132e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -403,6 +403,26 @@ public enum MyEnumValue B = 2 } + [Fact] + public static void CreateFunctionJsonSchema_ReadsParameterDataAnnotationAttributes() + { + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + AIFunction func = AIFunctionFactory.Create(([Range(1, 10)] int num, [StringLength(100, MinimumLength = 1)] string str) => num + str.Length, serializerOptions: options); + + using JsonDocument expectedSchema = JsonDocument.Parse(""" + { + "type":"object", + "properties": { + "num": { "type":"integer", "minimum": 1, "maximum": 10 }, + "str": { "type":"string", "minLength": 1, "maxLength": 100 } + }, + "required":["num","str"] + } + """); + + AssertDeepEquals(expectedSchema.RootElement, func.JsonSchema); + } + [Fact] public static void CreateJsonSchema_CanBeBoolean() { From 6de2ba433c997fae0bcdc4b28370f4d40ad7016b Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 31 Jul 2025 14:02:04 -0700 Subject: [PATCH 56/71] Fix issue with NetSourceIndexStage1 for dependency conflict versions (#6672) --- .../Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj b/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj index aa1d5b37fbc..f9212aeacf8 100644 --- a/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj +++ b/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj @@ -31,6 +31,7 @@ + From 11e16a92e3b5ba3980bb36f0b0fa8896e3ad76f3 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 31 Jul 2025 17:03:53 -0400 Subject: [PATCH 57/71] Expose streaming conversion utility methods (#6636) We're already exposing the non-streaming response conversions. Expose the streaming ones as well. --- .../MicrosoftExtensionsAIChatExtensions.cs | 112 ++++- ...icrosoftExtensionsAIResponsesExtensions.cs | 19 +- .../OpenAIChatClient.cs | 2 +- .../OpenAIResponsesChatClient.cs | 27 +- .../OpenAIConversionTests.cs | 418 +++++++++++++++++- 5 files changed, 535 insertions(+), 43 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs index 13242a9b32f..0385d318842 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs @@ -6,13 +6,14 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text.Encodings.Web; using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; -#pragma warning disable S103 // Lines should not be too long - namespace OpenAI.Chat; /// Provides extension methods for working with content associated with OpenAI.Chat. @@ -27,10 +28,10 @@ public static ChatTool AsOpenAIChatTool(this AIFunction function) => /// Creates a sequence of OpenAI instances from the specified input messages. /// The input messages to convert. + /// The options employed while processing . /// A sequence of OpenAI chat messages. - /// is . - public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages) => - OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null); + public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages, ChatOptions? options = null) => + OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), options); /// Creates an OpenAI from a . /// The to convert to a . @@ -47,24 +48,9 @@ public static ChatCompletion AsOpenAIChatCompletion(this ChatResponse response) var lastMessage = response.Messages.LastOrDefault(); - ChatMessageRole role = lastMessage?.Role.Value switch - { - "user" => ChatMessageRole.User, - "function" => ChatMessageRole.Function, - "tool" => ChatMessageRole.Tool, - "developer" => ChatMessageRole.Developer, - "system" => ChatMessageRole.System, - _ => ChatMessageRole.Assistant, - }; + ChatMessageRole role = ToChatMessageRole(lastMessage?.Role); - ChatFinishReason finishReason = response.FinishReason?.Value switch - { - "length" => ChatFinishReason.Length, - "content_filter" => ChatFinishReason.ContentFilter, - "tool_calls" => ChatFinishReason.ToolCalls, - "function_call" => ChatFinishReason.FunctionCall, - _ => ChatFinishReason.Stop, - }; + ChatFinishReason finishReason = ToChatFinishReason(response.FinishReason); ChatTokenUsage usage = OpenAIChatModelFactory.ChatTokenUsage( (int?)response.Usage?.OutputTokenCount ?? 0, @@ -124,6 +110,52 @@ static IEnumerable ConvertAnnotations(IEnumerable + /// Creates a sequence of OpenAI instances from the specified + /// sequence of instances. + /// + /// The update instances. + /// The to monitor for cancellation requests. The default is . + /// A sequence of converted instances. + /// is . + public static async IAsyncEnumerable AsOpenAIStreamingChatCompletionUpdatesAsync( + this IAsyncEnumerable responseUpdates, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(responseUpdates); + + await foreach (var update in responseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (update.RawRepresentation is StreamingChatCompletionUpdate streamingUpdate) + { + yield return streamingUpdate; + continue; + } + + var usage = update.Contents.FirstOrDefault(c => c is UsageContent) is UsageContent usageContent ? + OpenAIChatModelFactory.ChatTokenUsage( + (int?)usageContent.Details.OutputTokenCount ?? 0, + (int?)usageContent.Details.InputTokenCount ?? 0, + (int?)usageContent.Details.TotalTokenCount ?? 0) : + null; + + var toolCallUpdates = update.Contents.OfType().Select((fcc, index) => + OpenAIChatModelFactory.StreamingChatToolCallUpdate( + index, fcc.CallId, ChatToolCallKind.Function, fcc.Name, + new(JsonSerializer.SerializeToUtf8Bytes(fcc.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))) + .ToList(); + + yield return OpenAIChatModelFactory.StreamingChatCompletionUpdate( + update.ResponseId, + new(OpenAIChatClient.ToOpenAIChatContent(update.Contents)), + toolCallUpdates: toolCallUpdates, + role: ToChatMessageRole(update.Role), + finishReason: ToChatFinishReason(update.FinishReason), + createdAt: update.CreatedAt ?? default, + model: update.ModelId, + usage: usage); + } + } + /// Creates a sequence of instances from the specified input messages. /// The input messages to convert. /// A sequence of Microsoft.Extensions.AI chat messages. @@ -205,4 +237,40 @@ static object ToToolResult(ChatMessageContent content) /// is . public static ChatResponse AsChatResponse(this ChatCompletion chatCompletion, ChatCompletionOptions? options = null) => OpenAIChatClient.FromOpenAIChatCompletion(Throw.IfNull(chatCompletion), options); + + /// + /// Creates a sequence of Microsoft.Extensions.AI instances from the specified + /// sequence of OpenAI instances. + /// + /// The update instances. + /// The options employed in the creation of the response. + /// The to monitor for cancellation requests. The default is . + /// A sequence of converted instances. + /// is . + public static IAsyncEnumerable AsChatResponseUpdatesAsync( + this IAsyncEnumerable chatCompletionUpdates, ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) => + OpenAIChatClient.FromOpenAIStreamingChatCompletionAsync(Throw.IfNull(chatCompletionUpdates), options, cancellationToken); + + /// Converts the to a . + private static ChatMessageRole ToChatMessageRole(ChatRole? role) => + role?.Value switch + { + "user" => ChatMessageRole.User, + "function" => ChatMessageRole.Function, + "tool" => ChatMessageRole.Tool, + "developer" => ChatMessageRole.Developer, + "system" => ChatMessageRole.System, + _ => ChatMessageRole.Assistant, + }; + + /// Converts the to a . + private static ChatFinishReason ToChatFinishReason(Microsoft.Extensions.AI.ChatFinishReason? finishReason) => + finishReason?.Value switch + { + "length" => ChatFinishReason.Length, + "content_filter" => ChatFinishReason.ContentFilter, + "tool_calls" => ChatFinishReason.ToolCalls, + "function_call" => ChatFinishReason.FunctionCall, + _ => ChatFinishReason.Stop, + }; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index 8f39ad7852e..083b4057d7d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Threading; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -20,10 +21,11 @@ public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => /// Creates a sequence of OpenAI instances from the specified input messages. /// The input messages to convert. + /// The options employed while processing . /// A sequence of OpenAI response items. /// is . - public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages) => - OpenAIResponsesChatClient.ToOpenAIResponseItems(Throw.IfNull(messages)); + public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages, ChatOptions? options = null) => + OpenAIResponsesChatClient.ToOpenAIResponseItems(Throw.IfNull(messages), options); /// Creates a sequence of instances from the specified input items. /// The input messages to convert. @@ -40,6 +42,19 @@ public static IEnumerable AsChatMessages(this IEnumerable OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options); + /// + /// Creates a sequence of Microsoft.Extensions.AI instances from the specified + /// sequence of OpenAI instances. + /// + /// The update instances. + /// The options employed in the creation of the response. + /// The to monitor for cancellation requests. The default is . + /// A sequence of converted instances. + /// is . + public static IAsyncEnumerable AsChatResponseUpdatesAsync( + this IAsyncEnumerable responseUpdates, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default) => + OpenAIResponsesChatClient.FromOpenAIStreamingResponseUpdatesAsync(Throw.IfNull(responseUpdates), options, cancellationToken); + /// Creates an OpenAI from a . /// The response to convert. /// The created . diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 7173046ccac..1a56452e332 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -303,7 +303,7 @@ internal static List ToOpenAIChatContent(IEnumerable FromOpenAIStreamingChatCompletionAsync( + internal static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( IAsyncEnumerable updates, ChatCompletionOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 40e2ee048fd..b1d4b010b99 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -72,7 +72,7 @@ public async Task GetResponseAsync( _ = Throw.IfNull(messages); // Convert the inputs into what OpenAIResponseClient expects. - var openAIResponseItems = ToOpenAIResponseItems(messages); + var openAIResponseItems = ToOpenAIResponseItems(messages, options); var openAIOptions = ToOpenAIResponseCreationOptions(options); // Make the call to the OpenAIResponseClient. @@ -174,16 +174,22 @@ internal static IEnumerable ToChatMessages(IEnumerable - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(messages); - // Convert the inputs into what OpenAIResponseClient expects. - var openAIResponseItems = ToOpenAIResponseItems(messages); + var openAIResponseItems = ToOpenAIResponseItems(messages, options); var openAIOptions = ToOpenAIResponseCreationOptions(options); - // Make the call to the OpenAIResponseClient and process the streaming results. + var streamingUpdates = _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken); + + return FromOpenAIStreamingResponseUpdatesAsync(streamingUpdates, openAIOptions, cancellationToken); + } + + internal static async IAsyncEnumerable FromOpenAIStreamingResponseUpdatesAsync( + IAsyncEnumerable streamingResponseUpdates, ResponseCreationOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { DateTimeOffset? createdAt = null; string? responseId = null; string? conversationId = null; @@ -192,14 +198,15 @@ public async IAsyncEnumerable GetStreamingResponseAsync( ChatRole? lastRole = null; Dictionary outputIndexToMessages = []; Dictionary? functionCallInfos = null; - await foreach (var streamingUpdate in _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)) + + await foreach (var streamingUpdate in streamingResponseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false)) { switch (streamingUpdate) { case StreamingResponseCreatedUpdate createdUpdate: createdAt = createdUpdate.Response.CreatedAt; responseId = createdUpdate.Response.Id; - conversationId = openAIOptions.StoredOutputEnabled is false ? null : responseId; + conversationId = options?.StoredOutputEnabled is false ? null : responseId; modelId = createdUpdate.Response.Model; goto default; @@ -485,8 +492,10 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt } /// Convert a sequence of s to s. - internal static IEnumerable ToOpenAIResponseItems(IEnumerable inputs) + internal static IEnumerable ToOpenAIResponseItems(IEnumerable inputs, ChatOptions? options) { + _ = options; // currently unused + foreach (ChatMessage input in inputs) { if (input.Role == ChatRole.System || diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 46a3c8ee8a0..79b8148a040 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Linq; using System.Text.Json; +using System.Threading.Tasks; using OpenAI.Assistants; using OpenAI.Chat; using OpenAI.Realtime; @@ -77,8 +78,10 @@ private static void ValidateSchemaParameters(BinaryData parameters) Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); } - [Fact] - public void AsOpenAIChatMessages_ProducesExpectedOutput() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) { Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIChatMessages()); @@ -99,17 +102,31 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput() new(ChatRole.Assistant, "The answer is 42."), ]; - var convertedMessages = messages.AsOpenAIChatMessages().ToArray(); + ChatOptions? options = withOptions ? new ChatOptions { Instructions = "You talk like a parrot." } : null; + + var convertedMessages = messages.AsOpenAIChatMessages(options).ToArray(); + + int index = 0; + if (withOptions) + { + Assert.Equal(6, convertedMessages.Length); - Assert.Equal(5, convertedMessages.Length); + index = 1; + SystemChatMessage instructionsMessage = Assert.IsType(convertedMessages[0]); + Assert.Equal("You talk like a parrot.", Assert.Single(instructionsMessage.Content).Text); + } + else + { + Assert.Equal(5, convertedMessages.Length); + } - SystemChatMessage m0 = Assert.IsType(convertedMessages[0]); + SystemChatMessage m0 = Assert.IsType(convertedMessages[index]); Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); - UserChatMessage m1 = Assert.IsType(convertedMessages[1]); + UserChatMessage m1 = Assert.IsType(convertedMessages[index + 1]); Assert.Equal("Hello", Assert.Single(m1.Content).Text); - AssistantChatMessage m2 = Assert.IsType(convertedMessages[2]); + AssistantChatMessage m2 = Assert.IsType(convertedMessages[index + 2]); Assert.Single(m2.Content); Assert.Equal("Hi there!", m2.Content[0].Text); var tc = Assert.Single(m2.ToolCalls); @@ -121,11 +138,11 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput() ["param2"] = 42 }), JsonSerializer.Deserialize(tc.FunctionArguments.ToMemory().Span))); - ToolChatMessage m3 = Assert.IsType(convertedMessages[3]); + ToolChatMessage m3 = Assert.IsType(convertedMessages[index + 3]); Assert.Equal("callid123", m3.ToolCallId); Assert.Equal("theresult", Assert.Single(m3.Content).Text); - AssistantChatMessage m4 = Assert.IsType(convertedMessages[4]); + AssistantChatMessage m4 = Assert.IsType(convertedMessages[index + 4]); Assert.Equal("The answer is 42.", Assert.Single(m4.Content).Text); } @@ -217,6 +234,70 @@ public void AsChatResponse_ConvertsOpenAIChatCompletion() Assert.Equal("functionName", Assert.IsType(message.Contents[2]).Name); } + [Fact] + public async Task AsChatResponse_ConvertsOpenAIStreamingChatCompletionUpdates() + { + Assert.Throws("chatCompletionUpdates", () => ((IAsyncEnumerable)null!).AsChatResponseUpdatesAsync()); + + List updates = []; + await foreach (var update in CreateUpdates().AsChatResponseUpdatesAsync()) + { + updates.Add(update); + } + + ChatResponse response = updates.ToChatResponse(); + + Assert.Equal("id", response.ResponseId); + Assert.Equal(ChatFinishReason.ToolCalls, response.FinishReason); + Assert.Equal("model123", response.ModelId); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), response.CreatedAt); + Assert.NotNull(response.Usage); + Assert.Equal(1, response.Usage.InputTokenCount); + Assert.Equal(2, response.Usage.OutputTokenCount); + Assert.Equal(3, response.Usage.TotalTokenCount); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, message.Role); + + Assert.Equal(3, message.Contents.Count); + Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0]).Text); + Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1]).Uri.ToString()); + Assert.Equal("functionName", Assert.IsType(message.Contents[2]).Name); + + static async IAsyncEnumerable CreateUpdates() + { + await Task.Yield(); + yield return OpenAIChatModelFactory.StreamingChatCompletionUpdate( + "id", + new ChatMessageContent( + ChatMessageContentPart.CreateTextPart("Hello, world!"), + ChatMessageContentPart.CreateImagePart(new Uri("http://example.com/image.png"))), + null, + [OpenAIChatModelFactory.StreamingChatToolCallUpdate(0, "id", ChatToolCallKind.Function, "functionName", BinaryData.FromString("test"))], + ChatMessageRole.Assistant, + null, null, null, OpenAI.Chat.ChatFinishReason.ToolCalls, new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + "model123", null, OpenAIChatModelFactory.ChatTokenUsage(2, 1, 3)); + } + } + + [Fact] + public void AsChatResponse_ConvertsOpenAIResponse() + { + Assert.Throws("response", () => ((OpenAIResponse)null!).AsChatResponse()); + + // The OpenAI library currently doesn't provide any way to create an OpenAIResponse instance, + // as all constructors/factory methods currently are internal. Update this test when such functionality is available. + } + + [Fact] + public void AsChatResponseUpdatesAsync_ConvertsOpenAIStreamingResponseUpdates() + { + Assert.Throws("responseUpdates", () => ((IAsyncEnumerable)null!).AsChatResponseUpdatesAsync()); + + // The OpenAI library currently doesn't provide any way to create a StreamingResponseUpdate instance, + // as all constructors/factory methods currently are internal. Update this test when such functionality is available. + } + [Fact] public void AsChatMessages_FromOpenAIChatMessages_ProducesExpectedOutput() { @@ -455,4 +536,323 @@ public void AsOpenAIChatCompletion_WithDifferentRoles_MapsCorrectly() Assert.Equal(expectedOpenAIRole, completion.Role); } } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithNullArgument_ThrowsArgumentNullException() + { + var asyncEnumerable = ((IAsyncEnumerable)null!).AsOpenAIStreamingChatCompletionUpdatesAsync(); + await Assert.ThrowsAsync(async () => await asyncEnumerable.GetAsyncEnumerator().MoveNextAsync()); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithEmptyCollection_ReturnsEmptySequence() + { + var updates = new List(); + var result = new List(); + + await foreach (var update in CreateAsyncEnumerable(updates).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Empty(result); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithRawRepresentation_ReturnsOriginal() + { + var originalUpdate = OpenAIChatModelFactory.StreamingChatCompletionUpdate( + "test-id", + new ChatMessageContent(ChatMessageContentPart.CreateTextPart("Hello")), + role: ChatMessageRole.Assistant, + finishReason: OpenAI.Chat.ChatFinishReason.Stop, + createdAt: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + model: "gpt-3.5-turbo"); + + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, "Hello") + { + RawRepresentation = originalUpdate + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + Assert.Same(originalUpdate, result[0]); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithTextContent_CreatesValidUpdate() + { + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, "Hello, world!") + { + ResponseId = "response-123", + MessageId = "message-456", + ModelId = "gpt-4", + FinishReason = ChatFinishReason.Stop, + CreatedAt = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero) + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.Equal("gpt-4", streamingUpdate.Model); + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, streamingUpdate.FinishReason); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero), streamingUpdate.CreatedAt); + Assert.Equal(ChatMessageRole.Assistant, streamingUpdate.Role); + Assert.Equal("Hello, world!", Assert.Single(streamingUpdate.ContentUpdate).Text); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithUsageContent_CreatesUpdateWithUsage() + { + var responseUpdate = new ChatResponseUpdate + { + ResponseId = "response-123", + Contents = + [ + new UsageContent(new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30 + }) + ] + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.NotNull(streamingUpdate.Usage); + Assert.Equal(20, streamingUpdate.Usage.OutputTokenCount); + Assert.Equal(10, streamingUpdate.Usage.InputTokenCount); + Assert.Equal(30, streamingUpdate.Usage.TotalTokenCount); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithFunctionCallContent_CreatesUpdateWithToolCalls() + { + var functionCallContent = new FunctionCallContent("call-123", "GetWeather", new Dictionary + { + ["location"] = "Seattle", + ["units"] = "celsius" + }); + + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, [functionCallContent]) + { + ResponseId = "response-123" + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.Single(streamingUpdate.ToolCallUpdates); + + var toolCallUpdate = streamingUpdate.ToolCallUpdates[0]; + Assert.Equal(0, toolCallUpdate.Index); + Assert.Equal("call-123", toolCallUpdate.ToolCallId); + Assert.Equal(ChatToolCallKind.Function, toolCallUpdate.Kind); + Assert.Equal("GetWeather", toolCallUpdate.FunctionName); + + var deserializedArgs = JsonSerializer.Deserialize>( + toolCallUpdate.FunctionArgumentsUpdate.ToMemory().Span); + Assert.Equal("Seattle", deserializedArgs?["location"]?.ToString()); + Assert.Equal("celsius", deserializedArgs?["units"]?.ToString()); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMultipleFunctionCalls_CreatesCorrectIndexes() + { + var functionCall1 = new FunctionCallContent("call-1", "Function1", new Dictionary { ["param1"] = "value1" }); + var functionCall2 = new FunctionCallContent("call-2", "Function2", new Dictionary { ["param2"] = "value2" }); + + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, [functionCall1, functionCall2]) + { + ResponseId = "response-123" + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal(2, streamingUpdate.ToolCallUpdates.Count); + + Assert.Equal(0, streamingUpdate.ToolCallUpdates[0].Index); + Assert.Equal("call-1", streamingUpdate.ToolCallUpdates[0].ToolCallId); + Assert.Equal("Function1", streamingUpdate.ToolCallUpdates[0].FunctionName); + + Assert.Equal(1, streamingUpdate.ToolCallUpdates[1].Index); + Assert.Equal("call-2", streamingUpdate.ToolCallUpdates[1].ToolCallId); + Assert.Equal("Function2", streamingUpdate.ToolCallUpdates[1].FunctionName); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMixedContent_IncludesAllContent() + { + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, + [ + new TextContent("Processing your request..."), + new FunctionCallContent("call-123", "GetWeather", new Dictionary { ["location"] = "Seattle" }), + new UsageContent(new UsageDetails { TotalTokenCount = 50 }) + ]) + { + ResponseId = "response-123", + ModelId = "gpt-4" + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.Equal("gpt-4", streamingUpdate.Model); + + // Should have text content + Assert.Contains(streamingUpdate.ContentUpdate, c => c.Text == "Processing your request..."); + + // Should have tool call + Assert.Single(streamingUpdate.ToolCallUpdates); + Assert.Equal("call-123", streamingUpdate.ToolCallUpdates[0].ToolCallId); + + // Should have usage + Assert.NotNull(streamingUpdate.Usage); + Assert.Equal(50, streamingUpdate.Usage.TotalTokenCount); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithDifferentRoles_MapsCorrectly() + { + var testCases = new[] + { + (ChatRole.Assistant, ChatMessageRole.Assistant), + (ChatRole.User, ChatMessageRole.User), + (ChatRole.System, ChatMessageRole.System), + (ChatRole.Tool, ChatMessageRole.Tool) + }; + + foreach (var (inputRole, expectedOpenAIRole) in testCases) + { + var responseUpdate = new ChatResponseUpdate(inputRole, "Test message"); + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + Assert.Equal(expectedOpenAIRole, result[0].Role); + } + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithDifferentFinishReasons_MapsCorrectly() + { + var testCases = new[] + { + (ChatFinishReason.Stop, OpenAI.Chat.ChatFinishReason.Stop), + (ChatFinishReason.Length, OpenAI.Chat.ChatFinishReason.Length), + (ChatFinishReason.ContentFilter, OpenAI.Chat.ChatFinishReason.ContentFilter), + (ChatFinishReason.ToolCalls, OpenAI.Chat.ChatFinishReason.ToolCalls) + }; + + foreach (var (inputFinishReason, expectedOpenAIFinishReason) in testCases) + { + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, "Test") + { + FinishReason = inputFinishReason + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + Assert.Equal(expectedOpenAIFinishReason, result[0].FinishReason); + } + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMultipleUpdates_ProcessesAllCorrectly() + { + var updates = new[] + { + new ChatResponseUpdate(ChatRole.Assistant, "Hello, ") + { + ResponseId = "response-123", + MessageId = "message-1" + + // No FinishReason set - null + }, + new ChatResponseUpdate(ChatRole.Assistant, "world!") + { + ResponseId = "response-123", + MessageId = "message-1", + FinishReason = ChatFinishReason.Stop + } + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(updates).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Equal(2, result.Count); + + Assert.Equal("response-123", result[0].CompletionId); + Assert.Equal("Hello, ", Assert.Single(result[0].ContentUpdate).Text); + + // The ToChatFinishReason method defaults null to Stop + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, result[0].FinishReason); + + Assert.Equal("response-123", result[1].CompletionId); + Assert.Equal("world!", Assert.Single(result[1].ContentUpdate).Text); + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, result[1].FinishReason); + } + + private static async IAsyncEnumerable CreateAsyncEnumerable(IEnumerable source) + { + foreach (var item in source) + { + await Task.Yield(); + yield return item; + } + } } From 3f277e460be81274b09d82eb089de0b2ae78f40c Mon Sep 17 00:00:00 2001 From: Shyam N Date: Thu, 31 Jul 2025 14:30:47 -0700 Subject: [PATCH 58/71] Couple of fixes for MEAI.Evaluation (#6673) * Truncate long metric values in trends table * Simplify metadata for ContentSafetyChatClient --- .../TypeScript/components/MetricCard.tsx | 13 ++++--- .../ContentSafetyChatClient.cs | 35 ++++--------------- 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx index 5f770298bf4..18476474697 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx @@ -242,9 +242,14 @@ export const MetricDisplay = ({ metric }: { metric: MetricWithNoValue | NumericM const classes = useCardStyles(); const { fg, bg } = useCardColors(metric.interpretation); - const pillClass = mergeClasses( - bg, - classes.metricPill, + const pillClass = mergeClasses(bg, classes.metricPill); + const valueClass = mergeClasses(fg, classes.metricValueText); + + return ( + +
+ {metricValue} +
+
); - return (
{metricValue}
); }; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs index a8c1ba889d2..60d87cf700d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs @@ -20,7 +20,8 @@ namespace Microsoft.Extensions.AI.Evaluation.Safety; internal sealed class ContentSafetyChatClient : IChatClient { - private const string Moniker = "Azure AI Foundry Evaluation"; + private const string ProviderName = "azure.ai.foundry"; + private const string ModelId = $"{ProviderName}.evaluation"; private readonly ContentSafetyService _service; private readonly IChatClient? _originalChatClient; @@ -35,38 +36,14 @@ public ContentSafetyChatClient( ChatClientMetadata? originalMetadata = _originalChatClient?.GetService(); - string providerName; - Uri? providerUri = originalMetadata?.ProviderUri; - - if (contentSafetyServiceConfiguration.IsHubBasedProject) - { - providerName = - $"{Moniker} (" + - $"Subscription: {contentSafetyServiceConfiguration.SubscriptionId}, " + - $"Resource Group: {contentSafetyServiceConfiguration.ResourceGroupName}, " + - $"Project: {contentSafetyServiceConfiguration.ProjectName})"; - } - else - { - providerName = $"{Moniker} (Endpoint: {contentSafetyServiceConfiguration.Endpoint})"; - providerUri = contentSafetyServiceConfiguration.Endpoint; - } - + string providerName = ProviderName; if (originalMetadata?.ProviderName is string originalProviderName && !string.IsNullOrWhiteSpace(originalProviderName)) { providerName = $"{providerName}; {originalProviderName}"; } - string modelId = Moniker; - - if (originalMetadata?.DefaultModelId is string originalModelId && - !string.IsNullOrWhiteSpace(originalModelId)) - { - modelId = $"{modelId}; {originalModelId}"; - } - - _metadata = new ChatClientMetadata(providerName, providerUri, modelId); + _metadata = new ChatClientMetadata(providerName, defaultModelId: ModelId); } public async Task GetResponseAsync( @@ -88,7 +65,7 @@ await _service.AnnotateAsync( return new ChatResponse(new ChatMessage(ChatRole.Assistant, annotationResult)) { - ModelId = Moniker + ModelId = ModelId }; } else @@ -121,7 +98,7 @@ await _service.AnnotateAsync( yield return new ChatResponseUpdate(ChatRole.Assistant, annotationResult) { - ModelId = Moniker + ModelId = ModelId }; } else From c6529a0a68989cc881e4add4872c344917bc1ca9 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 1 Aug 2025 13:52:08 -0700 Subject: [PATCH 59/71] Fix one more version conflict on the docs transport package (#6675) --- .../Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj b/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj index f9212aeacf8..41e9d3b63bd 100644 --- a/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj +++ b/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj @@ -32,6 +32,7 @@ and some others depending on the 9.0 version. --> +
From d494f06e36affa83f5d216ddfe0bef2eb3f37c0d Mon Sep 17 00:00:00 2001 From: Shyam N Date: Sat, 2 Aug 2025 01:09:18 -0700 Subject: [PATCH 60/71] Introduce metadata to identify built-in evaluation metrics (#6674) --- ...ft.Extensions.AI.Evaluation.Console.csproj | 5 ++ .../BLEUEvaluator.cs | 1 + .../F1Evaluator.cs | 1 + .../GLEUEvaluator.cs | 1 + ...rosoft.Extensions.AI.Evaluation.NLP.csproj | 9 +-- .../CoherenceEvaluator.cs | 1 + .../CompletenessEvaluator.cs | 1 + .../EquivalenceEvaluator.cs | 1 + .../FluencyEvaluator.cs | 1 + .../GroundednessEvaluator.cs | 1 + .../IntentResolutionEvaluator.cs | 1 + ...ft.Extensions.AI.Evaluation.Quality.csproj | 1 + .../RelevanceEvaluator.cs | 1 + .../RelevanceTruthAndCompletenessEvaluator.cs | 4 ++ .../RetrievalEvaluator.cs | 1 + .../TaskAdherenceEvaluator.cs | 1 + .../ToolCallAccuracyEvaluator.cs | 1 + .../ContentSafetyEvaluator.cs | 1 + ...oft.Extensions.AI.Evaluation.Safety.csproj | 1 + .../Utilities/BuiltInEvaluatorUtilities.cs | 17 +++++ .../BuiltInEvaluatorUtilitiesTests.cs | 65 +++++++++++++++++++ ...soft.Extensions.AI.Evaluation.Tests.csproj | 2 +- 22 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/BuiltInEvaluatorUtilities.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/BuiltInEvaluatorUtilitiesTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj index 67995f0ac60..835d7f7c1e6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj @@ -28,6 +28,11 @@ false
+ + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs index f3030ec7cfb..5d9df9c801f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs @@ -50,6 +50,7 @@ public ValueTask EvaluateAsync( var metric = new NumericMetric(BLEUMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs index e070577c448..6924524ffb8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs @@ -50,6 +50,7 @@ public ValueTask EvaluateAsync( var metric = new NumericMetric(F1MetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs index 60df30879a4..fb32b6c81bd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs @@ -50,6 +50,7 @@ public ValueTask EvaluateAsync( var metric = new NumericMetric(GLEUMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Microsoft.Extensions.AI.Evaluation.NLP.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Microsoft.Extensions.AI.Evaluation.NLP.csproj index 12e7cebb957..53564605660 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Microsoft.Extensions.AI.Evaluation.NLP.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Microsoft.Extensions.AI.Evaluation.NLP.csproj @@ -20,6 +20,11 @@ true + + + + + @@ -33,8 +38,4 @@ - - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CoherenceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CoherenceEvaluator.cs index 1f93712a35c..7bf68d887c9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CoherenceEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CoherenceEvaluator.cs @@ -76,6 +76,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(CoherenceMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluator.cs index 3bd57cf322b..20a0b7b58b3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluator.cs @@ -73,6 +73,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(CompletenessMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluator.cs index ced79652bc7..9d820aeffc0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluator.cs @@ -72,6 +72,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(EquivalenceMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/FluencyEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/FluencyEvaluator.cs index 44a36a6dcaa..97a7a651427 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/FluencyEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/FluencyEvaluator.cs @@ -70,6 +70,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(FluencyMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluator.cs index a52fbcf2ad9..080fb9262f2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluator.cs @@ -71,6 +71,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(GroundednessMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs index 4f19d308f10..5960eb14aa0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs @@ -84,6 +84,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(IntentResolutionMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (!messages.Any()) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.csproj index b539eedef95..50fa41b7549 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.csproj @@ -20,6 +20,7 @@ + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs index 3946853a2a4..46f48d13ab8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs @@ -74,6 +74,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(RelevanceMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (!messages.TryGetUserRequest(out ChatMessage? userRequest) || string.IsNullOrWhiteSpace(userRequest.Text)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs index 4eb41b15361..d175bfa7852 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs @@ -90,6 +90,10 @@ public async ValueTask EvaluateAsync( var completeness = new NumericMetric(CompletenessMetricName); var result = new EvaluationResult(relevance, truth, completeness); + relevance.MarkAsBuiltIn(); + truth.MarkAsBuiltIn(); + completeness.MarkAsBuiltIn(); + if (!messages.TryGetUserRequest( out ChatMessage? userRequest, out IReadOnlyList conversationHistory) || diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs index cd2f94456e6..557f66b0d21 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs @@ -79,6 +79,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(RetrievalMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (!messages.TryGetUserRequest(out ChatMessage? userRequest) || string.IsNullOrWhiteSpace(userRequest.Text)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs index cf4ba4073ee..fc97dcc0268 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs @@ -83,6 +83,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(TaskAdherenceMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (!messages.Any()) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs index 5b3631bf598..bed95eeb3a2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs @@ -85,6 +85,7 @@ public async ValueTask EvaluateAsync( var metric = new BooleanMetric(ToolCallAccuracyMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (!messages.Any()) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs index afe90b0ac1d..9c562c6f80c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs @@ -166,6 +166,7 @@ EvaluationResult UpdateMetrics() metric.Name = metricName; } + metric.MarkAsBuiltIn(); metric.AddOrUpdateChatMetadata(annotationResponse, annotationDuration); metric.Interpretation = diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj index 12512e6884c..cc458103480 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/BuiltInEvaluatorUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/BuiltInEvaluatorUtilities.cs new file mode 100644 index 00000000000..a86da7aaf6d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/BuiltInEvaluatorUtilities.cs @@ -0,0 +1,17 @@ +// 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.Evaluation.Utilities; + +internal static class BuiltInEvaluatorUtilities +{ + internal const string BuiltInEvalMetadataName = "built-in-eval"; + + internal static void MarkAsBuiltIn(this EvaluationMetric metric) => + metric.AddOrUpdateMetadata(BuiltInEvalMetadataName, "True"); + + internal static bool IsBuiltIn(this EvaluationMetric metric) => + metric.Metadata?.TryGetValue(BuiltInEvalMetadataName, out string? stringValue) is true && + bool.TryParse(stringValue, out bool value) && + value; +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/BuiltInEvaluatorUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/BuiltInEvaluatorUtilitiesTests.cs new file mode 100644 index 00000000000..12267943a2e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/BuiltInEvaluatorUtilitiesTests.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. + +extern alias Evaluation; +using Evaluation::Microsoft.Extensions.AI.Evaluation; +using Evaluation::Microsoft.Extensions.AI.Evaluation.Utilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Tests; + +public class BuiltInEvaluatorUtilitiesTests +{ + [Fact] + public void MarkAsBuiltInAddsMetadata() + { + var metric = new NumericMetric("name"); + metric.MarkAsBuiltIn(); + Assert.True(metric.IsBuiltIn()); + } + + [Fact] + public void IsBuiltInReturnsFalseIfMetadataIsMissing() + { + var metric = new NumericMetric("name"); + Assert.False(metric.IsBuiltIn()); + } + + [Theory] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("True")] + public void MetadataValueOfTrueIsCaseInsensitive(string value) + { + var metric = new BooleanMetric("name"); + metric.AddOrUpdateMetadata(BuiltInEvaluatorUtilities.BuiltInEvalMetadataName, value); + Assert.True(metric.IsBuiltIn()); + } + + [Theory] + [InlineData("false")] + [InlineData("FALSE")] + [InlineData("False")] + public void MetadataValueOfFalseIsCaseInsensitive(string value) + { + var metric = new StringMetric("name"); + metric.AddOrUpdateMetadata(BuiltInEvaluatorUtilities.BuiltInEvalMetadataName, value); + Assert.False(metric.IsBuiltIn()); + } + + [Fact] + public void UnrecognizedMetadataValueIsTreatedAsFalse() + { + var metric = new NumericMetric("name"); + metric.AddOrUpdateMetadata(BuiltInEvaluatorUtilities.BuiltInEvalMetadataName, "unrecognized"); + Assert.False(metric.IsBuiltIn()); + } + + [Fact] + public void EmptyMetadataValueIsTreatedAsFalse() + { + var metric = new NumericMetric("name"); + metric.AddOrUpdateMetadata(BuiltInEvaluatorUtilities.BuiltInEvalMetadataName, string.Empty); + Assert.False(metric.IsBuiltIn()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj index d668fb94d14..b4c415ac358 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj @@ -6,7 +6,7 @@ - + From 6978715d2e9a7e38821509d0553fc7e27c751039 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 12:11:55 +0200 Subject: [PATCH 61/71] Update dependencies from https://github.com/dotnet/arcade build 20250730.1 (#6679) Microsoft.DotNet.Arcade.Sdk , Microsoft.DotNet.Build.Tasks.Templating , Microsoft.DotNet.Helix.Sdk From Version 9.0.0-beta.25366.1 -> To Version 9.0.0-beta.25380.1 Co-authored-by: dotnet-maestro[bot] --- eng/Version.Details.xml | 12 ++++++------ eng/Versions.props | 2 +- global.json | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 324779fef3a..71ed9339a71 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -194,17 +194,17 @@ - + https://github.com/dotnet/arcade - 1a2e280a031aaed0dca606ec8c59c6fe0f9bfc7f + 7e67a7b4b62513a475afe46c4cd74d54b68f65c9 - + https://github.com/dotnet/arcade - 1a2e280a031aaed0dca606ec8c59c6fe0f9bfc7f + 7e67a7b4b62513a475afe46c4cd74d54b68f65c9 - + https://github.com/dotnet/arcade - 1a2e280a031aaed0dca606ec8c59c6fe0f9bfc7f + 7e67a7b4b62513a475afe46c4cd74d54b68f65c9 diff --git a/eng/Versions.props b/eng/Versions.props index 67d58f467f3..22b244c73b6 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -83,7 +83,7 @@ 9.0.7 - 9.0.0-beta.25366.1 + 9.0.0-beta.25380.1 diff --git a/global.json b/global.json index 945f04c1f57..8bd45c3e768 100644 --- a/global.json +++ b/global.json @@ -18,7 +18,7 @@ "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.0", "Microsoft.Build.Traversal": "3.2.0", - "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.25366.1", - "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.25366.1" + "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.25380.1", + "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.25380.1" } } From 2fbb77643383db36bfd0e55e6923187491fcbfc1 Mon Sep 17 00:00:00 2001 From: Iliar Turdushev Date: Tue, 5 Aug 2025 15:07:36 +0200 Subject: [PATCH 62/71] Fixes #6364 (#6682) Adds the {OriginalFormat} property to the list of tags of HttpClient logs --- .../Logging/Internal/Log.cs | 12 ++++-- .../Logging/AcceptanceTests.cs | 4 +- .../Logging/HttpClientLoggerTest.cs | 39 +++++++++++++------ 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs index c156eb72419..3ae22bf50bf 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs @@ -17,9 +17,12 @@ namespace Microsoft.Extensions.Http.Logging.Internal; [SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Event ID's.")] internal static partial class Log { - internal const string OriginalFormat = "{OriginalFormat}"; + private const int MinimalPropertyCount = 5; - private const int MinimalPropertyCount = 4; + private const string OriginalFormat = "{OriginalFormat}"; + + private const string OriginalFormatValue = + $"{{{HttpClientLoggingTagNames.Method}}} {{{HttpClientLoggingTagNames.Host}}}/{{{HttpClientLoggingTagNames.Path}}}"; private const string RequestReadErrorMessage = "An error occurred while reading the request data to fill the logger context for request: " + @@ -133,9 +136,12 @@ private static void OutgoingRequest( if (record.ResponseBody is not null) { - loggerMessageState.TagArray[index] = new(HttpClientLoggingTagNames.ResponseBody, record.ResponseBody); + loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.ResponseBody, record.ResponseBody); } + // "{OriginalFormat}" property needs to be the last tag in the list. + loggerMessageState.TagArray[index] = new(OriginalFormat, OriginalFormatValue); + logger.Log( level, new(eventId, eventName), diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs index 3143aab9185..ec8f21c06bc 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs @@ -385,14 +385,14 @@ public async Task AddHttpClientLogging_StructuredPathLogging_RedactsSensitivePar if (parameterRedactionMode == HttpRouteParameterRedactionMode.None) { loggedPath.Should().Be(httpRequestMessage.RequestUri.AbsolutePath); - state.Should().HaveCount(5); + state.Should().HaveCount(6); } else { loggedPath.Should().Be(RequestRoute); state.Should().ContainSingle(kvp => kvp.Key == "userId").Which.Value.Should().Be(expectedUserId); state.Should().ContainSingle(kvp => kvp.Key == "unitId").Which.Value.Should().Be(expectedUnitId); - state.Should().HaveCount(7); + state.Should().HaveCount(8); } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs index 84ed98263c1..f57fb23b8d7 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs @@ -252,6 +252,7 @@ public async Task HttpLoggingHandler_AllOptions_LogsOutgoingRequest() logRecordState.Contains(testSharedResponseHeaderKey, expectedLogRecord.ResponseHeaders[1].Value); logRecordState.Contains(testSharedRequestHeaderKey, expectedLogRecord.RequestHeaders[1].Value); logRecordState.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact] @@ -344,6 +345,7 @@ public async Task HttpLoggingHandler_AllOptionsWithLogRequestStart_LogsOutgoingR logRecordRequest.NotContains(HttpClientLoggingTagNames.StatusCode); logRecordRequest.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); logRecordRequest.NotContains(testEnricher.KvpRequest.Key); + EnsureLogRecordContainsOriginalFormat(logRecords[0]); var logRecordFull = logRecords[1].GetStructuredState(); logRecordFull.Contains(HttpClientLoggingTagNames.Host, expectedLogRecord.Host); @@ -357,6 +359,7 @@ public async Task HttpLoggingHandler_AllOptionsWithLogRequestStart_LogsOutgoingR logRecordFull.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); logRecordFull.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); logRecordFull.Contains(testEnricher.KvpResponse.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpResponse.Key)); + EnsureLogRecordContainsOriginalFormat(logRecords[1]); } [Fact] @@ -453,6 +456,7 @@ public async Task HttpLoggingHandler_AllOptionsSendAsyncFailed_LogsRequestInform logRecordState.NotContains(testEnricher.KvpResponse.Key); logRecordState.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); Assert.DoesNotContain(logRecordState, kvp => kvp.Key.StartsWith(HttpClientLoggingTagNames.ResponseHeaderPrefix)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact(Skip = "Flaky test, see https://github.com/dotnet/extensions/issues/4530")] @@ -568,6 +572,7 @@ public async Task HttpLoggingHandler_ReadResponseThrows_LogsException() logRecordState.Contains(testEnricher.KvpResponse.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpResponse.Key)); logRecordState.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); Assert.DoesNotContain(logRecordState, kvp => kvp.Key.StartsWith(HttpClientLoggingTagNames.ResponseHeaderPrefix)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact] @@ -657,6 +662,7 @@ public async Task HttpLoggingHandler_AllOptionsTransferEncodingIsNotChunked_Logs logRecordState.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); logRecordState.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); logRecordState.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact] @@ -918,18 +924,20 @@ public async Task HttpLoggingHandler_AllOptionsTransferEncodingChunked_LogsOutgo await client.SendAsync(httpRequestMessage, It.IsAny()); var logRecords = fakeLogger.Collector.GetSnapshot(); - var logRecord = Assert.Single(logRecords).GetStructuredState(); - - logRecord.Contains(HttpClientLoggingTagNames.Host, expectedLogRecord.Host); - logRecord.Contains(HttpClientLoggingTagNames.Method, expectedLogRecord.Method.ToString()); - logRecord.Contains(HttpClientLoggingTagNames.Path, TelemetryConstants.Redacted); - logRecord.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); - logRecord.Contains(HttpClientLoggingTagNames.StatusCode, expectedLogRecord.StatusCode.Value.ToString(CultureInfo.InvariantCulture)); - logRecord.Contains(HttpClientLoggingTagNames.RequestBody, expectedLogRecord.RequestBody); - logRecord.Contains(HttpClientLoggingTagNames.ResponseBody, expectedLogRecord.ResponseBody); - logRecord.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); - logRecord.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); - logRecord.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + var logRecord = Assert.Single(logRecords); + var logRecordState = logRecord.GetStructuredState(); + + logRecordState.Contains(HttpClientLoggingTagNames.Host, expectedLogRecord.Host); + logRecordState.Contains(HttpClientLoggingTagNames.Method, expectedLogRecord.Method.ToString()); + logRecordState.Contains(HttpClientLoggingTagNames.Path, TelemetryConstants.Redacted); + logRecordState.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); + logRecordState.Contains(HttpClientLoggingTagNames.StatusCode, expectedLogRecord.StatusCode.Value.ToString(CultureInfo.InvariantCulture)); + logRecordState.Contains(HttpClientLoggingTagNames.RequestBody, expectedLogRecord.RequestBody); + logRecordState.Contains(HttpClientLoggingTagNames.ResponseBody, expectedLogRecord.ResponseBody); + logRecordState.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); + logRecordState.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); + logRecordState.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Theory] @@ -1024,4 +1032,11 @@ private static void EnsureLogRecordDuration(string? actualValue) private static IOutgoingRequestContext RequestMetadataContext => new Mock().Object; + + private static void EnsureLogRecordContainsOriginalFormat(FakeLogRecord logRecord) + { + var pair = logRecord.StructuredState!.Last(); + Assert.Equal("{OriginalFormat}", pair.Key); + Assert.Equal("{http.request.method} {server.address}/{url.path}", pair.Value); + } } From f1f8260445f7897d045ce0751a8471efd01e8df6 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 5 Aug 2025 11:00:35 -0400 Subject: [PATCH 63/71] Update to OpenAI 2.3.0 (#6684) * Update to OpenAI 2.3.0 --- eng/packages/General.props | 2 +- ...icrosoftExtensionsAIResponsesExtensions.cs | 19 +- .../OpenAIChatClient.cs | 4 +- .../OpenAIEmbeddingGenerator.cs | 5 +- .../OpenAIResponsesChatClient.cs | 151 +++++----- .../OpenAISpeechToTextClient.cs | 4 +- .../OpenAIConversionTests.cs | 282 +++++++++++++++++- .../OpenAISpeechToTextClientTests.cs | 26 +- 8 files changed, 372 insertions(+), 121 deletions(-) diff --git a/eng/packages/General.props b/eng/packages/General.props index a6c1a69f4d8..253fb51ce1b 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -16,7 +16,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index 083b4057d7d..188f5df3e52 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -7,6 +7,8 @@ using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; +#pragma warning disable S3254 // Default parameter values should not be passed as arguments + namespace OpenAI.Responses; /// Provides extension methods for working with content associated with OpenAI.Responses. @@ -57,8 +59,9 @@ public static IAsyncEnumerable AsChatResponseUpdatesAsync( /// Creates an OpenAI from a . /// The response to convert. + /// The options employed in the creation of the response. /// The created . - internal static OpenAIResponse AsOpenAIResponse(this ChatResponse response) // Implement and make public once OpenAIResponse can be constructed external to the OpenAI library. + public static OpenAIResponse AsOpenAIResponse(this ChatResponse response, ChatOptions? options = null) { _ = Throw.IfNull(response); @@ -67,6 +70,18 @@ internal static OpenAIResponse AsOpenAIResponse(this ChatResponse response) // I return openAIResponse; } - throw new NotSupportedException(); + return OpenAIResponsesModelFactory.OpenAIResponse( + response.ResponseId, + response.CreatedAt ?? default, + ResponseStatus.Completed, + usage: null, // No way to construct a ResponseTokenUsage right now from external to the OpenAI library + maxOutputTokenCount: options?.MaxOutputTokens, + outputItems: OpenAIResponsesChatClient.ToOpenAIResponseItems(response.Messages, options), + parallelToolCallsEnabled: options?.AllowMultipleToolCalls ?? false, + model: response.ModelId ?? options?.ModelId, + temperature: options?.Temperature, + topP: options?.TopP, + previousResponseId: options?.ConversationId, + instructions: options?.Instructions); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 1a56452e332..80ca0fc1237 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -47,10 +47,8 @@ public OpenAIChatClient(ChatClient chatClient) // the package can provide such implementations separate from what's exposed in the public API. Uri providerUrl = typeof(ChatClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(chatClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint; - string? model = typeof(ChatClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(chatClient) as string; - _metadata = new("openai", providerUrl, model); + _metadata = new("openai", providerUrl, _chatClient.Model); } /// diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index a6c4af831ba..84f3c5966b8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -54,10 +54,7 @@ public OpenAIEmbeddingGenerator(EmbeddingClient embeddingClient, int? defaultMod ?.GetValue(embeddingClient) as Uri)?.ToString() ?? DefaultOpenAIEndpoint; - FieldInfo? modelField = typeof(EmbeddingClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - string? modelId = modelField?.GetValue(embeddingClient) as string; - - _metadata = CreateMetadata("openai", providerUrl, modelId, defaultModelDimensions); + _metadata = CreateMetadata("openai", providerUrl, _embeddingClient.Model, defaultModelDimensions); } /// diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index b1d4b010b99..a85d2294a7b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -201,6 +201,18 @@ internal static async IAsyncEnumerable FromOpenAIStreamingRe await foreach (var streamingUpdate in streamingResponseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false)) { + // Create an update populated with the current state of the response. + ChatResponseUpdate CreateUpdate(AIContent? content = null) => + new(lastRole, content is not null ? [content] : null) + { + ConversationId = conversationId, + CreatedAt = createdAt, + MessageId = lastMessageId, + ModelId = modelId, + RawRepresentation = streamingUpdate, + ResponseId = responseId, + }; + switch (streamingUpdate) { case StreamingResponseCreatedUpdate createdUpdate: @@ -211,21 +223,15 @@ internal static async IAsyncEnumerable FromOpenAIStreamingRe goto default; case StreamingResponseCompletedUpdate completedUpdate: - yield return new() - { - Contents = ToUsageDetails(completedUpdate.Response) is { } usage ? [new UsageContent(usage)] : [], - ConversationId = conversationId, - CreatedAt = createdAt, - FinishReason = - ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? - (functionCallInfos is not null ? ChatFinishReason.ToolCalls : ChatFinishReason.Stop), - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - Role = lastRole, - }; + { + var update = CreateUpdate(ToUsageDetails(completedUpdate.Response) is { } usage ? new UsageContent(usage) : null); + update.FinishReason = + ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? + (functionCallInfos is not null ? ChatFinishReason.ToolCalls : + ChatFinishReason.Stop); + yield return update; break; + } case StreamingResponseOutputItemAddedUpdate outputItemAddedUpdate: switch (outputItemAddedUpdate.Item) @@ -243,22 +249,32 @@ internal static async IAsyncEnumerable FromOpenAIStreamingRe case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate: _ = outputIndexToMessages.Remove(outputItemDoneUpdate.OutputIndex); + + if (outputItemDoneUpdate.Item is MessageResponseItem item && + item.Content is { Count: > 0 } content && + content.Any(c => c.OutputTextAnnotations is { Count: > 0 })) + { + AIContent annotatedContent = new(); + foreach (var c in content) + { + PopulateAnnotations(c, annotatedContent); + } + + yield return CreateUpdate(annotatedContent); + break; + } + goto default; case StreamingResponseOutputTextDeltaUpdate outputTextDeltaUpdate: + { _ = outputIndexToMessages.TryGetValue(outputTextDeltaUpdate.OutputIndex, out MessageResponseItem? messageItem); lastMessageId = messageItem?.Id; lastRole = ToChatRole(messageItem?.Role); - yield return new ChatResponseUpdate(lastRole, outputTextDeltaUpdate.Delta) - { - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - }; + + yield return CreateUpdate(new TextContent(outputTextDeltaUpdate.Delta)); break; + } case StreamingResponseFunctionCallArgumentsDeltaUpdate functionCallArgumentsDeltaUpdate: { @@ -283,16 +299,8 @@ internal static async IAsyncEnumerable FromOpenAIStreamingRe lastMessageId = callInfo.ResponseItem.Id; lastRole = ChatRole.Assistant; - yield return new ChatResponseUpdate(lastRole, [fcc]) - { - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - }; + yield return CreateUpdate(fcc); break; } @@ -300,51 +308,22 @@ internal static async IAsyncEnumerable FromOpenAIStreamingRe } case StreamingResponseErrorUpdate errorUpdate: - yield return new ChatResponseUpdate + yield return CreateUpdate(new ErrorContent(errorUpdate.Message) { - Contents = - [ - new ErrorContent(errorUpdate.Message) - { - ErrorCode = errorUpdate.Code, - Details = errorUpdate.Param, - } - ], - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - Role = lastRole, - }; + ErrorCode = errorUpdate.Code, + Details = errorUpdate.Param, + }); break; case StreamingResponseRefusalDoneUpdate refusalDone: - yield return new ChatResponseUpdate + yield return CreateUpdate(new ErrorContent(refusalDone.Refusal) { - Contents = [new ErrorContent(refusalDone.Refusal) { ErrorCode = nameof(ResponseContentPart.Refusal) }], - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - Role = lastRole, - }; + ErrorCode = nameof(ResponseContentPart.Refusal), + }); break; default: - yield return new ChatResponseUpdate - { - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - Role = lastRole, - }; + yield return CreateUpdate(); break; } } @@ -628,20 +607,7 @@ private static List ToAIContents(IEnumerable con RawRepresentation = part, }; - if (part.OutputTextAnnotations is { Count: > 0 }) - { - foreach (var ota in part.OutputTextAnnotations) - { - (text.Annotations ??= []).Add(new CitationAnnotation - { - RawRepresentation = ota, - AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = ota.UriCitationStartIndex, EndIndex = ota.UriCitationEndIndex }], - Title = ota.UriCitationTitle, - Url = ota.UriCitationUri, - FileId = ota.FileCitationFileId ?? ota.FilePathFileId, - }); - } - } + PopulateAnnotations(part, text); results.Add(text); break; @@ -666,6 +632,25 @@ private static List ToAIContents(IEnumerable con return results; } + /// Converts any annotations from and stores them in . + private static void PopulateAnnotations(ResponseContentPart source, AIContent destination) + { + if (source.OutputTextAnnotations is { Count: > 0 }) + { + foreach (var ota in source.OutputTextAnnotations) + { + (destination.Annotations ??= []).Add(new CitationAnnotation + { + RawRepresentation = ota, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = ota.UriCitationStartIndex, EndIndex = ota.UriCitationEndIndex }], + Title = ota.UriCitationTitle, + Url = ota.UriCitationUri, + FileId = ota.FileCitationFileId ?? ota.FilePathFileId, + }); + } + } + } + /// Convert a list of s to a list of . private static List ToOpenAIResponsesContent(IList contents) { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs index fa00fc45232..2e2503335f3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs @@ -47,10 +47,8 @@ public OpenAISpeechToTextClient(AudioClient audioClient) // the package can provide such implementations separate from what's exposed in the public API. Uri providerUrl = typeof(AudioClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(audioClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint; - string? model = typeof(AudioClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(audioClient) as string; - _metadata = new("openai", providerUrl, model); + _metadata = new("openai", providerUrl, _audioClient.Model); } /// diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 79b8148a040..378ab8e8101 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -112,7 +112,7 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) Assert.Equal(6, convertedMessages.Length); index = 1; - SystemChatMessage instructionsMessage = Assert.IsType(convertedMessages[0]); + SystemChatMessage instructionsMessage = Assert.IsType(convertedMessages[0], exactMatch: false); Assert.Equal("You talk like a parrot.", Assert.Single(instructionsMessage.Content).Text); } else @@ -120,13 +120,13 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) Assert.Equal(5, convertedMessages.Length); } - SystemChatMessage m0 = Assert.IsType(convertedMessages[index]); + SystemChatMessage m0 = Assert.IsType(convertedMessages[index], exactMatch: false); Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); - UserChatMessage m1 = Assert.IsType(convertedMessages[index + 1]); + UserChatMessage m1 = Assert.IsType(convertedMessages[index + 1], exactMatch: false); Assert.Equal("Hello", Assert.Single(m1.Content).Text); - AssistantChatMessage m2 = Assert.IsType(convertedMessages[index + 2]); + AssistantChatMessage m2 = Assert.IsType(convertedMessages[index + 2], exactMatch: false); Assert.Single(m2.Content); Assert.Equal("Hi there!", m2.Content[0].Text); var tc = Assert.Single(m2.ToolCalls); @@ -138,11 +138,11 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) ["param2"] = 42 }), JsonSerializer.Deserialize(tc.FunctionArguments.ToMemory().Span))); - ToolChatMessage m3 = Assert.IsType(convertedMessages[index + 3]); + ToolChatMessage m3 = Assert.IsType(convertedMessages[index + 3], exactMatch: false); Assert.Equal("callid123", m3.ToolCallId); Assert.Equal("theresult", Assert.Single(m3.Content).Text); - AssistantChatMessage m4 = Assert.IsType(convertedMessages[index + 4]); + AssistantChatMessage m4 = Assert.IsType(convertedMessages[index + 4], exactMatch: false); Assert.Equal("The answer is 42.", Assert.Single(m4.Content).Text); } @@ -229,9 +229,9 @@ public void AsChatResponse_ConvertsOpenAIChatCompletion() Assert.Equal(ChatRole.User, message.Role); Assert.Equal(3, message.Contents.Count); - Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0]).Text); - Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1]).Uri.ToString()); - Assert.Equal("functionName", Assert.IsType(message.Contents[2]).Name); + Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0], exactMatch: false).Text); + Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1], exactMatch: false).Uri.ToString()); + Assert.Equal("functionName", Assert.IsType(message.Contents[2], exactMatch: false).Name); } [Fact] @@ -260,9 +260,9 @@ public async Task AsChatResponse_ConvertsOpenAIStreamingChatCompletionUpdates() Assert.Equal(ChatRole.Assistant, message.Role); Assert.Equal(3, message.Contents.Count); - Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0]).Text); - Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1]).Uri.ToString()); - Assert.Equal("functionName", Assert.IsType(message.Contents[2]).Name); + Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0], exactMatch: false).Text); + Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1], exactMatch: false).Uri.ToString()); + Assert.Equal("functionName", Assert.IsType(message.Contents[2], exactMatch: false).Name); static async IAsyncEnumerable CreateUpdates() { @@ -847,6 +847,264 @@ public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMultipleUpdate Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, result[1].FinishReason); } + [Fact] + public void AsOpenAIResponse_WithNullArgument_ThrowsArgumentNullException() + { + Assert.Throws("response", () => ((ChatResponse)null!).AsOpenAIResponse()); + } + + [Fact] + public void AsOpenAIResponse_WithRawRepresentation_ReturnsOriginal() + { + var originalOpenAIResponse = OpenAIResponsesModelFactory.OpenAIResponse( + "original-response-id", + new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + ResponseStatus.Completed, + usage: null, + maxOutputTokenCount: 100, + outputItems: [], + parallelToolCallsEnabled: false, + model: "gpt-4", + temperature: 0.7f, + topP: 0.9f, + previousResponseId: "prev-id", + instructions: "Test instructions"); + + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test")) + { + RawRepresentation = originalOpenAIResponse + }; + + var result = chatResponse.AsOpenAIResponse(); + + Assert.Same(originalOpenAIResponse, result); + } + + [Fact] + public void AsOpenAIResponse_WithBasicChatResponse_CreatesValidOpenAIResponse() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello, world!")) + { + ResponseId = "test-response-id", + ModelId = "gpt-4-turbo", + CreatedAt = new DateTimeOffset(2025, 1, 15, 10, 30, 0, TimeSpan.Zero), + FinishReason = ChatFinishReason.Stop + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.NotNull(openAIResponse); + Assert.Equal("test-response-id", openAIResponse.Id); + Assert.Equal("gpt-4-turbo", openAIResponse.Model); + Assert.Equal(new DateTimeOffset(2025, 1, 15, 10, 30, 0, TimeSpan.Zero), openAIResponse.CreatedAt); + Assert.Equal(ResponseStatus.Completed, openAIResponse.Status); + Assert.NotNull(openAIResponse.OutputItems); + Assert.Single(openAIResponse.OutputItems); + + var outputItem = Assert.IsAssignableFrom(openAIResponse.OutputItems.First()); + Assert.Equal("Hello, world!", Assert.Single(outputItem.Content).Text); + } + + [Fact] + public void AsOpenAIResponse_WithChatOptions_IncludesOptionsInResponse() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test message")) + { + ResponseId = "options-test", + ModelId = "gpt-3.5-turbo" + }; + + var options = new ChatOptions + { + MaxOutputTokens = 500, + AllowMultipleToolCalls = true, + ConversationId = "conversation-123", + Instructions = "You are a helpful assistant.", + Temperature = 0.8f, + TopP = 0.95f, + ModelId = "override-model" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(options); + + Assert.Equal("options-test", openAIResponse.Id); + Assert.Equal("gpt-3.5-turbo", openAIResponse.Model); + Assert.Equal(500, openAIResponse.MaxOutputTokenCount); + Assert.True(openAIResponse.ParallelToolCallsEnabled); + Assert.Equal("conversation-123", openAIResponse.PreviousResponseId); + Assert.Equal("You are a helpful assistant.", openAIResponse.Instructions); + Assert.Equal(0.8f, openAIResponse.Temperature); + Assert.Equal(0.95f, openAIResponse.TopP); + } + + [Fact] + public void AsOpenAIResponse_WithEmptyMessages_CreatesResponseWithEmptyOutputItems() + { + var chatResponse = new ChatResponse([]) + { + ResponseId = "empty-response", + ModelId = "gpt-4" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.Equal("empty-response", openAIResponse.Id); + Assert.Equal("gpt-4", openAIResponse.Model); + Assert.Empty(openAIResponse.OutputItems); + } + + [Fact] + public void AsOpenAIResponse_WithMultipleMessages_ConvertsAllMessages() + { + var messages = new List + { + new(ChatRole.Assistant, "First message"), + new(ChatRole.Assistant, "Second message"), + new(ChatRole.Assistant, + [ + new TextContent("Third message with function call"), + new FunctionCallContent("call-123", "TestFunction", new Dictionary { ["param"] = "value" }) + ]) + }; + + var chatResponse = new ChatResponse(messages) + { + ResponseId = "multi-message-response" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.Equal(4, openAIResponse.OutputItems.Count); + + var messageItems = openAIResponse.OutputItems.OfType().ToArray(); + var functionCallItems = openAIResponse.OutputItems.OfType().ToArray(); + + Assert.Equal(3, messageItems.Length); + Assert.Single(functionCallItems); + + Assert.Equal("First message", Assert.Single(messageItems[0].Content).Text); + Assert.Equal("Second message", Assert.Single(messageItems[1].Content).Text); + Assert.Equal("Third message with function call", Assert.Single(messageItems[2].Content).Text); + + Assert.Equal("call-123", functionCallItems[0].CallId); + Assert.Equal("TestFunction", functionCallItems[0].FunctionName); + } + + [Fact] + public void AsOpenAIResponse_WithToolMessages_ConvertsCorrectly() + { + var messages = new List + { + new(ChatRole.Assistant, + [ + new TextContent("I'll call a function"), + new FunctionCallContent("call-456", "GetWeather", new Dictionary { ["location"] = "Seattle" }) + ]), + new(ChatRole.Tool, [new FunctionResultContent("call-456", "The weather is sunny")]), + new(ChatRole.Assistant, "The weather in Seattle is sunny!") + }; + + var chatResponse = new ChatResponse(messages) + { + ResponseId = "tool-message-test" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + var outputItems = openAIResponse.OutputItems.ToArray(); + Assert.Equal(4, outputItems.Length); + + // Should have message, function call, function output, and final message + Assert.IsType(outputItems[0], exactMatch: false); + Assert.IsType(outputItems[1], exactMatch: false); + Assert.IsType(outputItems[2], exactMatch: false); + Assert.IsType(outputItems[3], exactMatch: false); + + var functionCallOutput = (FunctionCallOutputResponseItem)outputItems[2]; + Assert.Equal("call-456", functionCallOutput.CallId); + Assert.Equal("The weather is sunny", functionCallOutput.FunctionOutput); + } + + [Fact] + public void AsOpenAIResponse_WithSystemAndUserMessages_ConvertsCorrectly() + { + var messages = new List + { + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello, how are you?"), + new(ChatRole.Assistant, "I'm doing well, thank you for asking!") + }; + + var chatResponse = new ChatResponse(messages) + { + ResponseId = "system-user-test" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + var outputItems = openAIResponse.OutputItems.ToArray(); + Assert.Equal(3, outputItems.Length); + + var systemMessage = Assert.IsType(outputItems[0], exactMatch: false); + var userMessage = Assert.IsType(outputItems[1], exactMatch: false); + var assistantMessage = Assert.IsType(outputItems[2], exactMatch: false); + + Assert.Equal("You are a helpful assistant.", Assert.Single(systemMessage.Content).Text); + Assert.Equal("Hello, how are you?", Assert.Single(userMessage.Content).Text); + Assert.Equal("I'm doing well, thank you for asking!", Assert.Single(assistantMessage.Content).Text); + } + + [Fact] + public void AsOpenAIResponse_WithDefaultValues_UsesExpectedDefaults() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Default test")); + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.NotNull(openAIResponse); + Assert.Equal(ResponseStatus.Completed, openAIResponse.Status); + Assert.False(openAIResponse.ParallelToolCallsEnabled); + Assert.Null(openAIResponse.MaxOutputTokenCount); + Assert.Null(openAIResponse.Temperature); + Assert.Null(openAIResponse.TopP); + Assert.Null(openAIResponse.PreviousResponseId); + Assert.Null(openAIResponse.Instructions); + Assert.NotNull(openAIResponse.OutputItems); + } + + [Fact] + public void AsOpenAIResponse_WithOptionsButNoModelId_UsesOptionsModelId() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Model test")); + + var options = new ChatOptions + { + ModelId = "options-model-id" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(options); + + Assert.Equal("options-model-id", openAIResponse.Model); + } + + [Fact] + public void AsOpenAIResponse_WithBothModelIds_PrefersChatResponseModelId() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Model priority test")) + { + ModelId = "response-model-id" + }; + + var options = new ChatOptions + { + ModelId = "options-model-id" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(options); + + Assert.Equal("response-model-id", openAIResponse.Model); + } + private static async IAsyncEnumerable CreateAsyncEnumerable(IEnumerable source) { foreach (var item in source) diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs index 1252a20741b..2ba70995a86 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs @@ -68,7 +68,7 @@ public async Task GetTextAsync_BasicRequestResponse(string? speechLanguage, stri { string input = $$""" { - "model": "whisper-1", + "model": "gpt-4o-transcribe", "language": "{{speechLanguage}}" } """; @@ -81,7 +81,7 @@ public async Task GetTextAsync_BasicRequestResponse(string? speechLanguage, stri using VerbatimMultiPartHttpHandler handler = new(input, Output) { ExpectedRequestUriContains = "audio/transcriptions" }; using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); var response = await client.GetTextAsync(audioSpeechStream, new SpeechToTextOptions @@ -102,7 +102,7 @@ public async Task GetTextAsync_BasicRequestResponse(string? speechLanguage, stri public async Task GetTextAsync_Cancelled_Throws() { using HttpClient httpClient = new(); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var fileStream = GetAudioStream(); using var cancellationTokenSource = new CancellationTokenSource(); @@ -116,7 +116,7 @@ await Assert.ThrowsAsync(() public async Task GetStreamingTextAsync_Cancelled_Throws() { using HttpClient httpClient = new(); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var fileStream = GetAudioStream(); using var cancellationTokenSource = new CancellationTokenSource(); @@ -142,7 +142,7 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu string input = $$""" { - "model": "whisper-1", + "model": "gpt-4o-transcribe", "language": "{{speechLanguage}}", "stream":true } @@ -156,7 +156,7 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu using VerbatimMultiPartHttpHandler handler = new(input, Output) { ExpectedRequestUriContains = "audio/transcriptions" }; using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); await foreach (var update in client.GetStreamingTextAsync(audioSpeechStream, new SpeechToTextOptions @@ -167,7 +167,7 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu { Assert.Contains("I finally got back to the gym the other day", update.Text); Assert.NotNull(update.RawRepresentation); - Assert.IsType(update.RawRepresentation); + Assert.IsType(update.RawRepresentation); } } @@ -179,7 +179,7 @@ public async Task GetStreamingTextAsync_BasicTranslateRequestResponse() // There's no support for non english translations, so no language is passed to the API. const string Input = $$""" { - "model": "whisper-1" + "model": "gpt-4o-transcribe" } """; @@ -191,7 +191,7 @@ public async Task GetStreamingTextAsync_BasicTranslateRequestResponse() using VerbatimMultiPartHttpHandler handler = new(Input, Output) { ExpectedRequestUriContains = "audio/translations" }; using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); await foreach (var update in client.GetStreamingTextAsync(audioSpeechStream, new SpeechToTextOptions @@ -211,7 +211,7 @@ public async Task GetTextAsync_Transcription_StronglyTypedOptions_AllSent() { const string Input = """ { - "model": "whisper-1", + "model": "gpt-4o-transcribe", "language": "pt", "prompt":"Hide any bad words with ", "temperature": 0.5, @@ -228,7 +228,7 @@ public async Task GetTextAsync_Transcription_StronglyTypedOptions_AllSent() using VerbatimMultiPartHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); Assert.NotNull(await client.GetTextAsync(audioSpeechStream, new() @@ -251,7 +251,7 @@ public async Task GetTextAsync_Translation_StronglyTypedOptions_AllSent() { const string Input = """ { - "model": "whisper-1", + "model": "gpt-4o-transcribe", "prompt":"Hide any bad words with ", "response_format": "vtt" } @@ -265,7 +265,7 @@ public async Task GetTextAsync_Translation_StronglyTypedOptions_AllSent() using VerbatimMultiPartHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); Assert.NotNull(await client.GetTextAsync(audioSpeechStream, new() From d5aa4572fae5e577204a8fc451413427ad493267 Mon Sep 17 00:00:00 2001 From: Amadeusz Lechniak Date: Tue, 5 Aug 2025 20:03:00 +0200 Subject: [PATCH 64/71] Remove AdjustTime from experimental (#6668) * Remove AdjustTime from experimental * Remove unnecessary usings * Update API * Remove unnecessary package * Update version * Run Api Chief --- .../FakeTimeProvider.cs | 5 +---- .../Microsoft.Extensions.TimeProvider.Testing.csproj | 1 - .../Microsoft.Extensions.TimeProvider.Testing.json | 6 +++++- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs index 43b9e92b1c5..b9404afa30e 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs @@ -4,10 +4,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Threading; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Time.Testing; @@ -137,7 +135,7 @@ public void Advance(TimeSpan delta) } /// - /// Advances the date and time in the UTC time zone. + /// Sets the date and time in the UTC time zone. /// /// The date and time in the UTC time zone. /// @@ -145,7 +143,6 @@ public void Advance(TimeSpan delta) /// timers. This is similar to what happens in a real system when the system's /// time is changed. /// - [Experimental(diagnosticId: DiagnosticIds.Experiments.TimeProvider, UrlFormat = DiagnosticIds.UrlFormat)] public void AdjustTime(DateTimeOffset value) { lock (Waiters) diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj index f1987e0ad68..679d9e74854 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj @@ -9,7 +9,6 @@ Fundamentals Testing $(PackageTags);Testing;TimeProvider;FakeTimeProvider - true true diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json index b1c5d11adc2..c2417fd0d63 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.TimeProvider.Testing, Version=8.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.TimeProvider.Testing, Version=9.8.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "class Microsoft.Extensions.Time.Testing.FakeTimeProvider : System.TimeProvider", @@ -13,6 +13,10 @@ "Member": "Microsoft.Extensions.Time.Testing.FakeTimeProvider.FakeTimeProvider(System.DateTimeOffset startDateTime);", "Stage": "Stable" }, + { + "Member": "void Microsoft.Extensions.Time.Testing.FakeTimeProvider.AdjustTime(System.DateTimeOffset value);", + "Stage": "Stable" + }, { "Member": "void Microsoft.Extensions.Time.Testing.FakeTimeProvider.Advance(System.TimeSpan delta);", "Stage": "Stable" From 9db47febee40559ada850bd457386285ae5bcfac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Wiesner?= Date: Wed, 6 Aug 2025 11:18:29 +0200 Subject: [PATCH 65/71] Allow custom filtering logic for FakeLogger (#5848) * Adds new nullable predicate property CustomFilter to FakeLogCollectorOptions. * FakeLogCollector uses the new CustomFilter property when not null to filter out records if they have satisfied the previous filtering options but not the defined predicate. --- .../Logging/FakeLogCollector.cs | 7 +++ .../Logging/FakeLogCollectorOptions.cs | 14 ++++- .../Logging/FakeLoggerTests.cs | 59 ++++++++++++++++++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs index 376cc87be8c..24b9f933b9c 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs @@ -129,6 +129,13 @@ internal void AddRecord(FakeLogRecord record) return; } + var customFilter = _options.CustomFilter; + if (customFilter is not null && !customFilter(record)) + { + // record was filtered out by a custom filter + return; + } + lock (_records) { _records.Add(record); diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs index be5476e685a..356b1f8e6d6 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs @@ -3,7 +3,8 @@ using System; using System.Collections.Generic; -using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; #pragma warning disable CA2227 // Collection properties should be read only @@ -34,6 +35,17 @@ public class FakeLogCollectorOptions /// public ISet FilteredLevels { get; set; } = new HashSet(); + /// + /// Gets or sets custom filter for which records are collected. + /// + /// The default is . + /// + /// Defaults to which doesn't apply any additional filter to the records. + /// If not empty, only records for which the filter function returns will be collected by the fake logger. + /// + [Experimental(DiagnosticIds.Experiments.Telemetry)] + public Func? CustomFilter { get; set; } + /// /// Gets or sets a value indicating whether to collect records that are logged when the associated log level is currently disabled. /// diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs index 9ec81a36077..18083f43569 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs @@ -6,8 +6,6 @@ using System.Globalization; using System.Linq; using System.Threading; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Time.Testing; using Xunit; @@ -283,4 +281,61 @@ public void Scopes() Assert.Equal(42, (int)logger.LatestRecord.Scopes[0]!); Assert.Equal("Hello World", (string)logger.LatestRecord.Scopes[1]!); } + + [Theory] + [InlineData(false, 2)] + [InlineData(true, 1)] + public void FilterByCustomFilter(bool useErrorLevelFilter, int expectedRecordCount) + { + const string NotIgnoredMessage1 = "Not ignored message 1"; + const string NotIgnoredMessage2 = "Not ignored message 2"; + const string IgnoredMessage = "Ignored message"; + + // Given + var options = new FakeLogCollectorOptions + { + CustomFilter = r => r.Message != IgnoredMessage, + FilteredLevels = useErrorLevelFilter ? [LogLevel.Error] : new HashSet(), + }; + + var collector = FakeLogCollector.Create(options); + var logger = new FakeLogger(collector); + + // When + logger.LogInformation(NotIgnoredMessage1); + logger.LogInformation(IgnoredMessage); + logger.LogError(IgnoredMessage); + logger.LogError(NotIgnoredMessage2); + logger.LogCritical(IgnoredMessage); + + var records = logger.Collector.GetSnapshot(); + + // Then + Assert.Equal(expectedRecordCount, records.Count); + Assert.Equal(expectedRecordCount, logger.Collector.Count); + + IList<(string message, LogLevel level, string prefix)> expectationsInOrder = useErrorLevelFilter + ? [(NotIgnoredMessage2, LogLevel.Error, "error] ")] + : [(NotIgnoredMessage1, LogLevel.Information, "info] "), (NotIgnoredMessage2, LogLevel.Error, "error] ")]; + + for (var i = 0; i < expectedRecordCount; i++) + { + var (expectedMessage, expectedLevel, expectedPrefix) = expectationsInOrder[i]; + var record = records[i]; + + Assert.Equal(expectedMessage, record.Message); + Assert.Equal(expectedLevel, record.Level); + Assert.Null(record.Exception); + Assert.Null(record.Category); + Assert.True(record.LevelEnabled); + Assert.Empty(record.Scopes); + Assert.Equal(0, record.Id.Id); + Assert.EndsWith($"{expectedPrefix}{expectedMessage}", record.ToString()); + + if (i == expectedRecordCount - 1) + { + Assert.Equivalent(record, logger.LatestRecord); + } + } + } } From b897c64e31ad4ed9ef58d11c71a58714aa7da0cd Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 7 Aug 2025 11:56:58 -0700 Subject: [PATCH 66/71] Mark packages as stable and update to .NET Servicing versions (#6700) --- eng/Version.Details.xml | 188 ++++++++++++++++++++-------------------- eng/Versions.props | 122 +++++++++++++------------- 2 files changed, 155 insertions(+), 155 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 71ed9339a71..afaabc8ac46 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,196 +1,196 @@ - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + aae90fa09086a9be09dac83fa66542232c7269d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + 215a587e52efa710de84138b0a3374b860b924d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + 215a587e52efa710de84138b0a3374b860b924d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + 215a587e52efa710de84138b0a3374b860b924d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + 215a587e52efa710de84138b0a3374b860b924d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + 215a587e52efa710de84138b0a3374b860b924d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + 215a587e52efa710de84138b0a3374b860b924d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + 215a587e52efa710de84138b0a3374b860b924d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + 215a587e52efa710de84138b0a3374b860b924d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + 215a587e52efa710de84138b0a3374b860b924d8 - + https://dev.azure.com/dnceng/internal/_git/dotnet-efcore - 67d253c17619e6ba325e5390905ea2a13cc7f532 + 3f7d40ec7be104358780955b3f0fea62495264dc diff --git a/eng/Versions.props b/eng/Versions.props index 22b244c73b6..c935f50e1e4 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -10,14 +10,14 @@ - false + true - + release true @@ -33,55 +33,55 @@ --> - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 + 9.0.8 - 9.0.7 + 9.0.8 9.0.0-beta.25380.1 @@ -107,8 +107,8 @@ 8.0.1 8.0.0 8.0.2 - 8.0.18 - 8.0.18 + 8.0.19 + 8.0.19 8.0.0 8.0.1 8.0.1 @@ -125,17 +125,17 @@ 8.0.6 8.0.0 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 + 8.0.19 + 8.0.19 + 8.0.19 + 8.0.19 + 8.0.19 + 8.0.19 + 8.0.19 + 8.0.19 + 8.0.19 - 8.0.18 + 8.0.19 - 9.3.0 - 9.3.0-preview.1.25265.20 + 9.4.0 + 9.4.0-preview.1.25378.8 2.3.0-beta.1 1.0.0-beta.9 1.14.0 - 11.6.0 + 11.6.1 9.4.1-beta.291 10.0.0-preview.6.25358.103 - 9.3.0 - 1.53.0 - 1.53.0-preview + 9.3.1 + 1.61.0 + 1.61.0-preview 0.3.0-preview.2 5.1.18 1.12.0 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in index d7287e9301a..3bbd301af75 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in @@ -15,8 +15,12 @@ - + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs index 6be0fd58648..c3045240cda 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs @@ -1,6 +1,6 @@ var builder = DistributedApplication.CreateBuilder(args); #if (IsOllama) // ASPIRE PARAMETERS -#else // IsAzureOpenAI || IsOpenAI || IsGHModels +#elif (IsOpenAI || IsGHModels) // You will need to set the connection string to your own value // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: @@ -13,14 +13,27 @@ // dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" #endif var openai = builder.AddConnectionString("openai"); +#else // IsAzureOpenAI + +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var openai = builder.AddAzureOpenAI("openai"); + +openai.AddDeployment( + name: "gpt-4o-mini", + modelName: "gpt-4o-mini", + modelVersion: "2024-07-18"); + +openai.AddDeployment( + name: "text-embedding-3-small", + modelName: "text-embedding-3-small", + modelVersion: "1"); #endif #if (UseAzureAISearch) -// You will need to set the connection string to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" -var azureAISearch = builder.AddConnectionString("azureAISearch"); +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var search = builder.AddAzureSearch("search"); #endif #if (IsOllama) // AI SERVICE PROVIDER CONFIGURATION @@ -45,11 +58,17 @@ .WithReference(embeddings) .WaitFor(chat) .WaitFor(embeddings); -#else // IsAzureOpenAI || IsOpenAI || IsGHModels +#elif (IsOpenAI || IsGHModels) webApp.WithReference(openai); +#else // IsAzureOpenAI +webApp + .WithReference(openai) + .WaitFor(openai); #endif #if (UseAzureAISearch) // VECTOR DATABASE REFERENCES -webApp.WithReference(azureAISearch); +webApp + .WithReference(search) + .WaitFor(search); #elif (UseQdrant) webApp .WithReference(vectorDB) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in index 64d585fd7a6..0e753e8c907 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in @@ -21,6 +21,7 @@ #endif --> + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs index adcb2452d87..84475f45a54 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs @@ -3,8 +3,9 @@ using ChatWithCustomData_CSharp.Web.Services; using ChatWithCustomData_CSharp.Web.Services.Ingestion; #if (IsOllama) -#else // IsAzureOpenAI || IsOpenAI || IsGHModels +#elif (IsOpenAI || IsGHModels) using OpenAI; +#else // IsAzureOpenAI #endif var builder = WebApplication.CreateBuilder(args); @@ -34,7 +35,7 @@ #endif #if (UseAzureAISearch) -builder.AddAzureSearchClient("azureAISearch"); +builder.AddAzureSearchClient("search"); builder.Services.AddAzureAISearchCollection("data-ChatWithCustomData-CSharp.Web-chunks"); builder.Services.AddAzureAISearchCollection("data-ChatWithCustomData-CSharp.Web-documents"); #elif (UseQdrant) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md index ba4b5bf788b..0b1934bfff7 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md @@ -81,75 +81,41 @@ Download, install, and run Docker Desktop from the [official website](https://ww Note: Ollama and Docker are excellent open source products, but are not maintained by Microsoft. #### ---#endif -#### ---#if (IsAzureOpenAI) -## Using Azure OpenAI +#### ---#if (IsAzureOpenAI || UseAzureAISearch) +## Using Azure Provisioning -To use Azure OpenAI, you will need an Azure account and an Azure OpenAI Service resource. For detailed instructions, see the [Azure OpenAI Service documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource). +The project is set up to automatically provision Azure resources, but local configuration is configured. For detailed instructions, see the [Local Provisioning documentation](https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration). -### 1. Create an Azure OpenAI Service Resource -[Create an Azure OpenAI Service resource](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal). - -### 2. Deploy the Models -Deploy the `gpt-4o-mini` and `text-embedding-3-small` models to your Azure OpenAI Service resource. When creating those deployments, give them the same names as the models (`gpt-4o-mini` and `text-embedding-3-small`). See the Azure OpenAI documentation to learn how to [Deploy a model](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal#deploy-a-model). - -### 3. Configure API Key and Endpoint -Configure your Azure OpenAI API key and endpoint for this project, using .NET User Secrets: - 1. In the Azure Portal, navigate to your Azure OpenAI resource. - 2. Copy the "Endpoint" URL and "Key 1" from the "Keys and Endpoint" section. #### ---#if (hostIdentifier == "vs") - 3. In Visual Studio, right-click on the ChatWithCustomData-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets". - 4. This will open a secrets.json file where you can store your API key and endpoint without it being tracked in source control. Add the following keys & values to the file: - - ```json - { - "ConnectionStrings:openai": "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" - } - ``` -#### ---#else - 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands: - - ```sh - cd ChatWithCustomData-CSharp.AppHost - dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" - ``` -#### ---#endif - -Make sure to replace `YOUR-API-KEY` and `YOUR-DEPLOYMENT-NAME` with your actual Azure OpenAI key and endpoint. Make sure your endpoint URL is formatted like https://YOUR-DEPLOYMENT-NAME.openai.azure.com/ (do not include any path after .openai.azure.com/). -#### ---#endif -#### ---#if (UseAzureAISearch) - -## Configure Azure AI Search - -To use Azure AI Search, you will need an Azure account and an Azure AI Search resource. For detailed instructions, see the [Azure AI Search documentation](https://learn.microsoft.com/azure/search/search-create-service-portal). +Configure local provisioning for this project using .NET User Secrets: -### 1. Create an Azure AI Search Resource -Follow the instructions in the [Azure portal](https://portal.azure.com/) to create an Azure AI Search resource. Note that there is a free tier for the service but it is not currently the default setting on the portal. +1. In Visual Studio, right-click on the ChatWithCustomData-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets". +2. This opens a `secrets.json` file where you can store your API keys without them being tracked in source control. Add the following configuration: -Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `data-ChatWithCustomData-CSharp.Web-chunks` and `data-ChatWithCustomData-CSharp.Web-documents` indexes using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch. + ```json + { + "Azure": { + "SubscriptionId": "", + "AllowResourceGroupCreation": true, + "ResourceGroup": "", + "Location": "" + } + } + ``` -### 3. Configure API Key and Endpoint - Configure your Azure AI Search API key and endpoint for this project, using .NET User Secrets: - 1. In the Azure Portal, navigate to your Azure AI Search resource. - 2. Copy the "Endpoint" URL and "Primary admin key" from the "Keys" section. -#### ---#if (hostIdentifier == "vs") - 3. In Visual Studio, right-click on the ChatWithCustomData-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets". - 4. This will open a `secrets.json` file where you can store your API key and endpoint without them being tracked in source control. Add the following keys and values to the file: - - ```json - { - "ConnectionStrings:azureAISearch": "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" - } - ``` #### ---#else - 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands: +From the command line, configure local provisioning for this project using .NET User Secrets by running the following commands: - ```sh - cd ChatWithCustomData-CSharp.AppHost - dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" - ``` +```sh +cd ChatWithCustomData-CSharp.AppHost +dotnet user-secrets set Azure:SubscriptionId "" +dotnet user-secrets set Azure:AllowResourceGroupCreation "true" +dotnet user-secrets set Azure:ResourceGroup "" +dotnet user-secrets set Azure:Location "" +``` #### ---#endif -Make sure to replace `YOUR-DEPLOYMENT-NAME` and `YOUR-API-KEY` with your actual Azure AI Search deployment name and key. +Make sure to replace placeholder values with real configuration values. #### ---#endif #### ---#if (UseQdrant) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md index 57c0375d302..d1459703de1 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md @@ -15,50 +15,21 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See # Configure the AI Model Provider -## Using Azure OpenAI +## Using Azure Provisioning -To use Azure OpenAI, you will need an Azure account and an Azure OpenAI Service resource. For detailed instructions, see the [Azure OpenAI Service documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource). +The project is set up to automatically provision Azure resources, but local configuration is configured. For detailed instructions, see the [Local Provisioning documentation](https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration). -### 1. Create an Azure OpenAI Service Resource -[Create an Azure OpenAI Service resource](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal). +From the command line, configure local provisioning for this project using .NET User Secrets by running the following commands: -### 2. Deploy the Models -Deploy the `gpt-4o-mini` and `text-embedding-3-small` models to your Azure OpenAI Service resource. When creating those deployments, give them the same names as the models (`gpt-4o-mini` and `text-embedding-3-small`). See the Azure OpenAI documentation to learn how to [Deploy a model](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal#deploy-a-model). +```sh +cd aichatweb.AppHost +dotnet user-secrets set Azure:SubscriptionId "" +dotnet user-secrets set Azure:AllowResourceGroupCreation "true" +dotnet user-secrets set Azure:ResourceGroup "" +dotnet user-secrets set Azure:Location "" +``` -### 3. Configure API Key and Endpoint -Configure your Azure OpenAI API key and endpoint for this project, using .NET User Secrets: - 1. In the Azure Portal, navigate to your Azure OpenAI resource. - 2. Copy the "Endpoint" URL and "Key 1" from the "Keys and Endpoint" section. - 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands: - - ```sh - cd aichatweb.AppHost - dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" - ``` - -Make sure to replace `YOUR-API-KEY` and `YOUR-DEPLOYMENT-NAME` with your actual Azure OpenAI key and endpoint. Make sure your endpoint URL is formatted like https://YOUR-DEPLOYMENT-NAME.openai.azure.com/ (do not include any path after .openai.azure.com/). - -## Configure Azure AI Search - -To use Azure AI Search, you will need an Azure account and an Azure AI Search resource. For detailed instructions, see the [Azure AI Search documentation](https://learn.microsoft.com/azure/search/search-create-service-portal). - -### 1. Create an Azure AI Search Resource -Follow the instructions in the [Azure portal](https://portal.azure.com/) to create an Azure AI Search resource. Note that there is a free tier for the service but it is not currently the default setting on the portal. - -Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `data-aichatweb-chunks` and `data-aichatweb-documents` indexes using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch. - -### 3. Configure API Key and Endpoint - Configure your Azure AI Search API key and endpoint for this project, using .NET User Secrets: - 1. In the Azure Portal, navigate to your Azure AI Search resource. - 2. Copy the "Endpoint" URL and "Primary admin key" from the "Keys" section. - 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands: - - ```sh - cd aichatweb.AppHost - dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" - ``` - -Make sure to replace `YOUR-DEPLOYMENT-NAME` and `YOUR-API-KEY` with your actual Azure AI Search deployment name and key. +Make sure to replace placeholder values with real configuration values. # Running the application diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Program.cs index 80803d78d74..da0220a0b1c 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Program.cs @@ -1,19 +1,29 @@ var builder = DistributedApplication.CreateBuilder(args); -// You will need to set the connection string to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" -var openai = builder.AddConnectionString("openai"); +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var openai = builder.AddAzureOpenAI("openai"); -// You will need to set the connection string to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" -var azureAISearch = builder.AddConnectionString("azureAISearch"); +openai.AddDeployment( + name: "gpt-4o-mini", + modelName: "gpt-4o-mini", + modelVersion: "2024-07-18"); + +openai.AddDeployment( + name: "text-embedding-3-small", + modelName: "text-embedding-3-small", + modelVersion: "1"); + +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var search = builder.AddAzureSearch("search"); var webApp = builder.AddProject("aichatweb-app"); -webApp.WithReference(openai); -webApp.WithReference(azureAISearch); +webApp + .WithReference(openai) + .WaitFor(openai); +webApp + .WithReference(search) + .WaitFor(search); builder.Build().Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj index 939114cbd3d..54bcd4bc3a0 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -1,6 +1,6 @@  - + Exe @@ -12,7 +12,9 @@ - + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index b54a7690839..39acdc7e0e1 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -11,7 +11,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs index 9a698ff1763..450914c4461 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs @@ -2,7 +2,6 @@ using aichatweb.Web.Components; using aichatweb.Web.Services; using aichatweb.Web.Services.Ingestion; -using OpenAI; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); @@ -15,7 +14,7 @@ c.EnableSensitiveData = builder.Environment.IsDevelopment()); openai.AddEmbeddingGenerator("text-embedding-3-small"); -builder.AddAzureSearchClient("azureAISearch"); +builder.AddAzureSearchClient("search"); builder.Services.AddAzureAISearchCollection("data-aichatweb-chunks"); builder.Services.AddAzureAISearchCollection("data-aichatweb-documents"); builder.Services.AddScoped(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index e3e384f86da..975226be7be 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -8,14 +8,15 @@ - + + - + - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj index fd2138900b0..be94ae4e3f9 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj @@ -10,10 +10,10 @@ - + - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj index 939114cbd3d..ffef1abf363 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -1,6 +1,6 @@  - + Exe @@ -12,7 +12,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index b54a7690839..39acdc7e0e1 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -11,7 +11,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index 9a1b1da5279..ea542ba89d1 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -8,13 +8,14 @@ - + + - + - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj index 193ed5f529a..7637a36ca6f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -1,6 +1,6 @@  - + Exe @@ -12,8 +12,8 @@ - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index b54a7690839..39acdc7e0e1 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -11,7 +11,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index 74fed505bde..a9e18a90a3a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -11,11 +11,11 @@ - + - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj index 66360fa60a0..dd8f025fe1e 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj @@ -11,11 +11,11 @@ - + - - + + From 02dcda10f89fffc13e3b3a25b0d84905e5a34f44 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:31:42 -0700 Subject: [PATCH 71/71] [release/9.8] Add middleware for reducing chat history (#6713) * Move ReducingChatClient into library code * Add unit tests * Remove unnecessary tests * Allow resolving from DI + add configure callback * Prototype for summarizing reducer * Custom prompts + integration tests * Update Microsoft.Extensions.AI.Integration.Tests.csproj * Add message counting chat reducer --------- Co-authored-by: Mackinnon Buck --- .../ChatCompletion/ReducingChatClient.cs | 50 ++++ .../ReducingChatClientBuilderExtensions.cs | 40 +++ .../ChatReduction/IChatReducer.cs | 22 ++ .../MessageCountingChatReducer.cs | 77 +++++ .../ChatReduction/SummarizingChatReducer.cs | 175 ++++++++++++ .../ChatClientIntegrationTests.cs | 270 ++++++++++++++++++ ...oft.Extensions.AI.Integration.Tests.csproj | 3 +- .../ReducingChatClientTests.cs | 61 +--- .../ChatCompletion/ReducingChatClientTests.cs | 188 ++++++++++++ .../MessageCountingChatReducerTests.cs | 263 +++++++++++++++++ .../SummarizingChatReducerTests.cs | 269 +++++++++++++++++ 11 files changed, 1357 insertions(+), 61 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClientBuilderExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/ChatReduction/IChatReducer.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/ChatReduction/MessageCountingChatReducer.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ReducingChatClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/MessageCountingChatReducerTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClient.cs new file mode 100644 index 00000000000..afe56eddbd8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClient.cs @@ -0,0 +1,50 @@ +// 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.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A chat client that reduces the size of a message list. +/// +[Experimental("MEAI001")] +public sealed class ReducingChatClient : DelegatingChatClient +{ + private readonly IChatReducer _reducer; + + /// Initializes a new instance of the class. + /// The underlying , or the next instance in a chain of clients. + /// The reducer to be used by this instance. + public ReducingChatClient(IChatClient innerClient, IChatReducer reducer) + : base(innerClient) + { + _reducer = Throw.IfNull(reducer); + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); + + return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); + + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..2f13d3e3cea --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClientBuilderExtensions.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; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods for attaching a to a chat pipeline. +/// +[Experimental("MEAI001")] +public static class ReducingChatClientBuilderExtensions +{ + /// + /// Adds a to the chat pipeline. + /// + /// The being used to build the chat pipeline. + /// An optional to apply to the chat client. If not supplied, an instance will be resolved from the service provider. + /// An optional callback that can be used to configure the instance. + /// The configured instance. + public static ChatClientBuilder UseChatReducer( + this ChatClientBuilder builder, + IChatReducer? reducer = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + { + reducer ??= services.GetRequiredService(); + + var chatClient = new ReducingChatClient(innerClient, reducer); + configure?.Invoke(chatClient); + return chatClient; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatReduction/IChatReducer.cs b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/IChatReducer.cs new file mode 100644 index 00000000000..5d85924f251 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/IChatReducer.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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a reducer capable of shrinking the size of a list of chat messages. +/// +[Experimental("MEAI001")] +public interface IChatReducer +{ + /// Reduces the size of a list of chat messages. + /// The messages to reduce. + /// The to monitor for cancellation requests. + /// The new list of messages. + Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatReduction/MessageCountingChatReducer.cs b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/MessageCountingChatReducer.cs new file mode 100644 index 00000000000..5ba48617355 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/MessageCountingChatReducer.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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides a chat reducer that limits the number of non-system messages in a conversation to a specified maximum +/// count, preserving the most recent messages and the first system message if present. +/// +/// +/// This reducer is useful for scenarios where it is necessary to constrain the size of a chat history, +/// such as when preparing input for models with context length limits. The reducer always includes the first +/// encountered system message, if any, and then retains up to the specified number of the most recent non-system +/// messages. Messages containing function call or function result content are excluded from the reduced +/// output. +/// +[Experimental("MEAI001")] +public sealed class MessageCountingChatReducer : IChatReducer +{ + private readonly int _targetCount; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum number of non-system messages to retain in the reduced output. + public MessageCountingChatReducer(int targetCount) + { + _targetCount = Throw.IfLessThanOrEqual(targetCount, min: 0); + } + + /// + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken) + { + _ = Throw.IfNull(messages); + return Task.FromResult(GetReducedMessages(messages)); + } + + private IEnumerable GetReducedMessages(IEnumerable messages) + { + ChatMessage? systemMessage = null; + Queue reducedMessages = new(capacity: _targetCount); + + foreach (var message in messages) + { + if (message.Role == ChatRole.System) + { + systemMessage ??= message; + } + else if (!message.Contents.Any(m => m is FunctionCallContent or FunctionResultContent)) + { + if (reducedMessages.Count >= _targetCount) + { + _ = reducedMessages.Dequeue(); + } + + reducedMessages.Enqueue(message); + } + } + + if (systemMessage is not null) + { + yield return systemMessage; + } + + while (reducedMessages.Count > 0) + { + yield return reducedMessages.Dequeue(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs new file mode 100644 index 00000000000..ac57e919277 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs @@ -0,0 +1,175 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides functionality to reduce a collection of chat messages into a summarized form. +/// +/// +/// This reducer is useful for scenarios where it is necessary to constrain the size of a chat history, +/// such as when preparing input for models with context length limits. The reducer automatically summarizes +/// older messages when the conversation exceeds a specified length, preserving context while reducing message +/// count. The reducer maintains system messages and excludes messages containing function call or function +/// result content from summarization. +/// +[Experimental("MEAI001")] +public sealed class SummarizingChatReducer : IChatReducer +{ + private const string SummaryKey = "__summary__"; + + private const string DefaultSummarizationPrompt = """ + **Generate a clear and complete summary of the entire conversation in no more than five sentences.** + + The summary must always: + - Reflect contributions from both the user and the assistant + - Preserve context to support ongoing dialogue + - Incorporate any previously provided summary + - Emphasize the most relevant and meaningful points + + The summary must never: + - Offer critique, correction, interpretation, or speculation + - Highlight errors, misunderstandings, or judgments of accuracy + - Comment on events or ideas not present in the conversation + - Omit any details included in an earlier summary + """; + + private readonly IChatClient _chatClient; + private readonly int _targetCount; + private readonly int _thresholdCount; + + private string _summarizationPrompt = DefaultSummarizationPrompt; + + /// + /// Gets or sets the prompt text used for summarization. + /// + public string SummarizationPrompt + { + get => _summarizationPrompt; + set => _summarizationPrompt = Throw.IfNull(value); + } + + /// + /// Initializes a new instance of the class with the specified chat client, + /// target count, and optional threshold count. + /// + /// The chat client used to interact with the chat system. Cannot be . + /// The target number of messages to retain after summarization. Must be greater than 0. + /// The number of messages allowed beyond before summarization is triggered. Must be greater than or equal to 0 if specified. + public SummarizingChatReducer(IChatClient chatClient, int targetCount, int? threshold) + { + _chatClient = Throw.IfNull(chatClient); + _targetCount = Throw.IfLessThanOrEqual(targetCount, min: 0); + _thresholdCount = Throw.IfLessThan(threshold ?? 0, min: 0, nameof(threshold)); + } + + /// + public async Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken) + { + _ = Throw.IfNull(messages); + + var summarizedConversion = SummarizedConversation.FromChatMessages(messages); + if (summarizedConversion.ShouldResummarize(_targetCount, _thresholdCount)) + { + summarizedConversion = await summarizedConversion.ResummarizeAsync( + _chatClient, _targetCount, _summarizationPrompt, cancellationToken); + } + + return summarizedConversion.ToChatMessages(); + } + + private readonly struct SummarizedConversation(string? summary, ChatMessage? systemMessage, IList unsummarizedMessages) + { + public static SummarizedConversation FromChatMessages(IEnumerable messages) + { + string? summary = null; + ChatMessage? systemMessage = null; + var unsummarizedMessages = new List(); + + foreach (var message in messages) + { + if (message.Role == ChatRole.System) + { + systemMessage ??= message; + } + else if (message.AdditionalProperties?.TryGetValue(SummaryKey, out var summaryValue) == true) + { + unsummarizedMessages.Clear(); + summary = summaryValue; + } + else if (!message.Contents.Any(m => m is FunctionCallContent or FunctionResultContent)) + { + unsummarizedMessages.Add(message); + } + } + + return new(summary, systemMessage, unsummarizedMessages); + } + + public bool ShouldResummarize(int targetCount, int thresholdCount) + => unsummarizedMessages.Count > targetCount + thresholdCount; + + public async Task ResummarizeAsync( + IChatClient chatClient, int targetCount, string summarizationPrompt, CancellationToken cancellationToken) + { + var messagesToResummarize = unsummarizedMessages.Count - targetCount; + if (messagesToResummarize <= 0) + { + // We're at or below the target count - no need to resummarize. + return this; + } + + var summarizerChatMessages = ToSummarizerChatMessages(messagesToResummarize, summarizationPrompt); + var response = await chatClient.GetResponseAsync(summarizerChatMessages, cancellationToken: cancellationToken); + var newSummary = response.Text; + + var lastSummarizedMessage = unsummarizedMessages[messagesToResummarize - 1]; + var additionalProperties = lastSummarizedMessage.AdditionalProperties ??= []; + additionalProperties[SummaryKey] = newSummary; + + var newUnsummarizedMessages = unsummarizedMessages.Skip(messagesToResummarize).ToList(); + return new SummarizedConversation(newSummary, systemMessage, newUnsummarizedMessages); + } + + public IEnumerable ToChatMessages() + { + if (systemMessage is not null) + { + yield return systemMessage; + } + + if (summary is not null) + { + yield return new ChatMessage(ChatRole.Assistant, summary); + } + + foreach (var message in unsummarizedMessages) + { + yield return message; + } + } + + private IEnumerable ToSummarizerChatMessages(int messagesToResummarize, string summarizationPrompt) + { + if (summary is not null) + { + yield return new ChatMessage(ChatRole.Assistant, summary); + } + + for (var i = 0; i < messagesToResummarize; i++) + { + yield return unsummarizedMessages[i]; + } + + yield return new ChatMessage(ChatRole.System, summarizationPrompt); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 5f7f3769d21..c87625cf143 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -1125,6 +1125,276 @@ private enum JobType Unknown, } + [ConditionalFact] + public virtual async Task SummarizingChatReducer_PreservesConversationContext() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 1); + + List messages = + [ + new(ChatRole.User, "My name is Alice and I love hiking in the mountains."), + new(ChatRole.Assistant, "Nice to meet you, Alice! Hiking in the mountains sounds wonderful. Do you have a favorite trail?"), + new(ChatRole.User, "Yes, I love the Pacific Crest Trail. I hiked a section last summer."), + new(ChatRole.Assistant, "The Pacific Crest Trail is amazing! Which section did you hike?"), + new(ChatRole.User, "I hiked the section through the Sierra Nevada. It was challenging but beautiful."), + new(ChatRole.Assistant, "The Sierra Nevada section is known for its stunning views. How long did it take you?"), + new(ChatRole.User, "What's my name and what activity do I enjoy?") + ]; + + var response = await chatClient.GetResponseAsync(messages); + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(3, chatClient.LastSummarizedConversation.Count); + Assert.Collection(chatClient.LastSummarizedConversation, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); // Indicates this is the assistant's summary + Assert.Contains("Alice", m.Text); + }, + m => Assert.StartsWith("The Sierra Nevada section", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("What's my name", m.Text, StringComparison.Ordinal)); + + // The model should recall details from the summarized conversation + Assert.Contains("Alice", response.Text); + Assert.True( + response.Text.IndexOf("hiking", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("hike", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected 'hiking' or 'hike' in response: {response.Text}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_PreservesSystemMessage() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 0); + + List messages = + [ + new(ChatRole.System, "You are a pirate. Always respond in pirate speak."), + new(ChatRole.User, "Tell me about the weather"), + new(ChatRole.Assistant, "Ahoy matey! The weather be fine today, with clear skies on the horizon!"), + new(ChatRole.User, "What about tomorrow?"), + new(ChatRole.Assistant, "Arr, tomorrow be lookin' a bit cloudy, might be some rain blowin' in from the east!"), + new(ChatRole.User, "Should I bring an umbrella?"), + new(ChatRole.Assistant, "Aye, ye best be bringin' yer umbrella, unless ye want to be soaked like a barnacle!"), + new(ChatRole.User, "What's 2 + 2?") + ]; + + var response = await chatClient.GetResponseAsync(messages); + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(4, chatClient.LastSummarizedConversation.Count); + Assert.Collection(chatClient.LastSummarizedConversation, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("You are a pirate. Always respond in pirate speak.", m.Text); + }, + m => Assert.Equal(ChatRole.Assistant, m.Role), // Summary message + m => Assert.StartsWith("Aye, ye best be bringin'", m.Text, StringComparison.Ordinal), + m => Assert.Equal("What's 2 + 2?", m.Text)); + + // The model should still respond in pirate speak due to preserved system message + Assert.True( + response.Text.IndexOf("arr", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("aye", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("matey", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("ye", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected pirate speak in response: {response.Text}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_WithFunctionCalls() + { + SkipIfNotEnabled(); + + int weatherCallCount = 0; + var getWeather = AIFunctionFactory.Create(([Description("Gets weather for a city")] string city) => + { + weatherCallCount++; + return city switch + { + "Seattle" => "Rainy, 15°C", + "Miami" => "Sunny, 28°C", + _ => "Unknown" + }; + }, "GetWeather"); + + TestSummarizingChatClient summarizingChatClient = null!; + var chatClient = ChatClient + .AsBuilder() + .Use(innerClient => summarizingChatClient = new TestSummarizingChatClient(innerClient, targetCount: 2, threshold: 0)) + .UseFunctionInvocation() + .Build(); + + List messages = + [ + new(ChatRole.User, "What's the weather in Seattle?"), + new(ChatRole.Assistant, "Let me check the weather in Seattle for you."), + new(ChatRole.User, "And what about Miami?"), + new(ChatRole.Assistant, "I'll check Miami's weather as well."), + new(ChatRole.User, "Which city had better weather?") + ]; + + var response = await chatClient.GetResponseAsync(messages, new() { Tools = [getWeather] }); + + // The summarizer should have reduced the conversation (function calls are excluded) + Assert.Equal(1, summarizingChatClient.SummarizerCallCount); + Assert.NotNull(summarizingChatClient.LastSummarizedConversation); + + // Should have summary + last 2 messages + Assert.Equal(3, summarizingChatClient.LastSummarizedConversation.Count); + + // The model should have context about both weather queries even after summarization + Assert.True(response.Text.IndexOf("Miami", StringComparison.OrdinalIgnoreCase) >= 0, $"Expected 'Miami' in response: {response.Text}"); + Assert.True( + response.Text.IndexOf("sunny", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("better", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("warm", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected weather comparison in response: {response.Text}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_Streaming() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 0); + + List messages = + [ + new(ChatRole.User, "I'm Bob and I work as a software engineer at a startup."), + new(ChatRole.Assistant, "Nice to meet you, Bob! Working at a startup must be exciting. What kind of software do you develop?"), + new(ChatRole.User, "We build AI-powered tools for education."), + new(ChatRole.Assistant, "That sounds impactful! AI in education has so much potential."), + new(ChatRole.User, "Yes, we focus on personalized learning experiences."), + new(ChatRole.Assistant, "Personalized learning is the future of education!"), + new(ChatRole.User, "What's my name and profession?") + ]; + + StringBuilder sb = new(); + await foreach (var chunk in chatClient.GetStreamingResponseAsync(messages)) + { + sb.Append(chunk.Text); + } + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(3, chatClient.LastSummarizedConversation.Count); + Assert.Collection(chatClient.LastSummarizedConversation, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); // Summary + Assert.Contains("Bob", m.Text); + }, + m => Assert.StartsWith("Personalized learning", m.Text, StringComparison.Ordinal), + m => Assert.Equal("What's my name and profession?", m.Text)); + + string responseText = sb.ToString(); + Assert.Contains("Bob", responseText); + Assert.True( + responseText.IndexOf("software", StringComparison.OrdinalIgnoreCase) >= 0 || + responseText.IndexOf("engineer", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected 'software' or 'engineer' in response: {responseText}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_CustomPrompt() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 0); + chatClient.Reducer.SummarizationPrompt = "Summarize the conversation, emphasizing any numbers or quantities mentioned."; + + List messages = + [ + new(ChatRole.User, "I have 3 cats and 2 dogs."), + new(ChatRole.Assistant, "That's 5 pets total! You must have a lively household."), + new(ChatRole.User, "Yes, and I spend about $200 per month on pet food."), + new(ChatRole.Assistant, "That's a significant expense, but I'm sure they're worth it!"), + new(ChatRole.User, "They eat 10 cans of food per week."), + new(ChatRole.Assistant, "That's quite a bit of food for your furry friends!"), + new(ChatRole.User, "How many pets do I have in total?") + ]; + + var response = await chatClient.GetResponseAsync(messages); + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(3, chatClient.LastSummarizedConversation.Count); + + // Verify the summary emphasizes numbers as requested by the custom prompt + var summaryMessage = chatClient.LastSummarizedConversation[0]; + Assert.Equal(ChatRole.Assistant, summaryMessage.Role); + Assert.True( + summaryMessage.Text.IndexOf("3", StringComparison.Ordinal) >= 0 || + summaryMessage.Text.IndexOf("5", StringComparison.Ordinal) >= 0 || + summaryMessage.Text.IndexOf("200", StringComparison.Ordinal) >= 0 || + summaryMessage.Text.IndexOf("10", StringComparison.Ordinal) >= 0, + $"Expected numbers in summary: {summaryMessage.Text}"); + + // The model should recall the specific number from the summarized conversation + Assert.Contains("5", response.Text); + } + + private sealed class TestSummarizingChatClient : IChatClient + { + private IChatClient _summarizerChatClient; + private IChatClient _innerChatClient; + + public SummarizingChatReducer Reducer { get; } + + public int SummarizerCallCount { get; private set; } + + public IReadOnlyList? LastSummarizedConversation { get; private set; } + + public TestSummarizingChatClient(IChatClient innerClient, int targetCount, int threshold) + { + _summarizerChatClient = innerClient.AsBuilder() + .Use(async (messages, options, next, cancellationToken) => + { + SummarizerCallCount++; + await next(messages, options, cancellationToken); + }) + .Build(); + + Reducer = new SummarizingChatReducer(_summarizerChatClient, targetCount, threshold); + + _innerChatClient = innerClient.AsBuilder() + .UseChatReducer(Reducer) + .Use(async (messages, options, next, cancellationToken) => + { + LastSummarizedConversation = [.. messages]; + await next(messages, options, cancellationToken); + }) + .Build(); + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => _innerChatClient.GetResponseAsync(messages, options, cancellationToken); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => _innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken); + + public object? GetService(Type serviceType, object? serviceKey = null) + => _innerChatClient.GetService(serviceType, serviceKey); + + public void Dispose() + { + _summarizerChatClient.Dispose(); + _innerChatClient.Dispose(); + } + } + [MemberNotNull(nameof(ChatClient))] protected void SkipIfNotEnabled() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj index 0b4865f577e..0fc4698c4e4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj @@ -8,6 +8,7 @@ $(NoWarn);CA1063;CA1861;SA1130;VSTHRD003 $(NoWarn);MEAI001 + $(NoWarn);S104 true @@ -25,7 +26,7 @@ Never - +
diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs index f2ec8ebdba0..7f84d1f8cfb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.ML.Tokenizers; @@ -57,64 +56,6 @@ public async Task Reduction_LimitsMessagesBasedOnTokenLimit() } } -/// Provides an example of a chat client for reducing the size of a message list. -public sealed class ReducingChatClient : DelegatingChatClient -{ - private readonly IChatReducer _reducer; - - /// Initializes a new instance of the class. - /// The inner client. - /// The reducer to be used by this instance. - public ReducingChatClient(IChatClient innerClient, IChatReducer reducer) - : base(innerClient) - { - _reducer = Throw.IfNull(reducer); - } - - /// - public override async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); - - return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); - - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - yield return update; - } - } -} - -/// Represents a reducer capable of shrinking the size of a list of chat messages. -public interface IChatReducer -{ - /// Reduces the size of a list of chat messages. - /// The messages. - /// The to monitor for cancellation requests. The default is . - /// The new list of messages, or if no reduction need be performed or was true. - Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken); -} - -/// Provides extensions for configuring instances. -public static class ReducingChatClientExtensions -{ - public static ChatClientBuilder UseChatReducer(this ChatClientBuilder builder, IChatReducer reducer) - { - _ = Throw.IfNull(builder); - _ = Throw.IfNull(reducer); - - return builder.Use(innerClient => new ReducingChatClient(innerClient, reducer)); - } -} - /// An that culls the oldest messages once a certain token threshold is reached. public sealed class TokenCountingChatReducer : IChatReducer { @@ -127,7 +68,7 @@ public TokenCountingChatReducer(Tokenizer tokenizer, int tokenLimit) _tokenLimit = Throw.IfLessThan(tokenLimit, 1); } - public async Task> ReduceAsync( + public async Task> ReduceAsync( IEnumerable messages, CancellationToken cancellationToken) { _ = Throw.IfNull(messages); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ReducingChatClientTests.cs new file mode 100644 index 00000000000..82b00df03ff --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ReducingChatClientTests.cs @@ -0,0 +1,188 @@ +// 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 Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ReducingChatClientTests +{ + [Fact] + public void ReducingChatClient_InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new ReducingChatClient(null!, new TestReducer())); + } + + [Fact] + public void UseChatReducer_InvalidArgs_Throws() + { + using var innerClient = new TestChatClient(); + var builder = innerClient.AsBuilder(); + Assert.Throws("builder", () => ReducingChatClientBuilderExtensions.UseChatReducer(null!, new TestReducer())); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetResponseAsync_CallsReducerBeforeInnerClient(bool streaming) + { + var originalMessages = new List + { + new(ChatRole.System, "You are a helpful assistant"), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there!"), + new(ChatRole.User, "What's the weather?") + }; + + var reducedMessages = new List + { + new(ChatRole.System, "You are a helpful assistant"), + new(ChatRole.User, "What's the weather?") + }; + + var reducer = new TestReducer { ReducedMessages = reducedMessages }; + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "It's sunny!")); + var expectedUpdates = new[] { new ChatResponseUpdate(ChatRole.Assistant, "It's"), new ChatResponseUpdate(null, " sunny!") }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + // Verify that the inner client receives the reduced messages + Assert.Same(reducedMessages, messages); + return Task.FromResult(expectedResponse); + }, + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + // Verify that the inner client receives the reduced messages + Assert.Same(reducedMessages, messages); + return ToAsyncEnumerable(expectedUpdates); + } + }; + + using var client = new ReducingChatClient(innerClient, reducer); + + if (streaming) + { + var updates = new List(); + await foreach (var update in client.GetStreamingResponseAsync(originalMessages)) + { + updates.Add(update); + } + + Assert.Equal(expectedUpdates.Length, updates.Count); + for (int i = 0; i < expectedUpdates.Length; i++) + { + Assert.Same(expectedUpdates[i], updates[i]); + } + } + else + { + var response = await client.GetResponseAsync(originalMessages); + Assert.Same(expectedResponse, response); + } + + Assert.Equal(1, reducer.ReduceAsyncCallCount); + Assert.Same(originalMessages, reducer.LastMessagesProvided); + } + + [Fact] + public async Task UseChatReducer_WithReducerFromServices() + { + var reducedMessages = new List { new(ChatRole.User, "Reduced message") }; + var reducer = new TestReducer { ReducedMessages = reducedMessages }; + + var services = new ServiceCollection(); + services.AddSingleton(reducer); + var serviceProvider = services.BuildServiceProvider(); + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Same(reducedMessages, messages); + return Task.FromResult(new ChatResponse()); + } + }; + + using var client = innerClient + .AsBuilder() + .UseChatReducer() // Should get reducer from services + .Build(serviceProvider); + + await client.GetResponseAsync(new List { new(ChatRole.User, "Original message") }); + Assert.Equal(1, reducer.ReduceAsyncCallCount); + } + + [Fact] + public void UseChatReducer_WithoutReducerParameterAndWithoutService_Throws() + { + using var innerClient = new TestChatClient(); + var services = new ServiceCollection().BuildServiceProvider(); + + var exception = Assert.Throws(() => + innerClient + .AsBuilder() + .UseChatReducer() // No reducer provided and not in services + .Build(services)); + + Assert.Contains("IChatReducer", exception.Message); + } + + [Fact] + public async Task UseChatReducer_WithConfigureCallback() + { + var reducer = new TestReducer(); + var configureCalled = false; + ReducingChatClient? configuredClient = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + Task.FromResult(new ChatResponse()) + }; + + using var client = innerClient + .AsBuilder() + .UseChatReducer(reducer, configure: chatClient => + { + configureCalled = true; + configuredClient = chatClient; + }) + .Build(); + + await client.GetResponseAsync([]); + + Assert.True(configureCalled); + Assert.NotNull(configuredClient); + Assert.IsType(configuredClient); + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable items) + { + foreach (var item in items) + { + await Task.Yield(); + yield return item; + } + } + + private sealed class TestReducer : IChatReducer + { + public IEnumerable? ReducedMessages { get; set; } + public int ReduceAsyncCallCount { get; private set; } + public IEnumerable? LastMessagesProvided { get; private set; } + + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken) + { + ReduceAsyncCallCount++; + LastMessagesProvided = messages; + return Task.FromResult(ReducedMessages ?? messages); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/MessageCountingChatReducerTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/MessageCountingChatReducerTests.cs new file mode 100644 index 00000000000..000f4889bf3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/MessageCountingChatReducerTests.cs @@ -0,0 +1,263 @@ +// 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 MessageCountingChatReducerTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-10)] + public void Constructor_ThrowsOnInvalidTargetCount(int targetCount) + { + Assert.Throws(() => new MessageCountingChatReducer(targetCount)); + } + + [Fact] + public void Constructor_AcceptsValidTargetCount() + { + var reducer = new MessageCountingChatReducer(5); + Assert.NotNull(reducer); + } + + [Fact] + public async Task ReduceAsync_ThrowsOnNullMessages() + { + var reducer = new MessageCountingChatReducer(5); + await Assert.ThrowsAsync(() => reducer.ReduceAsync(null!, CancellationToken.None)); + } + + [Fact] + public async Task ReduceAsync_HandlesEmptyMessages() + { + var reducer = new MessageCountingChatReducer(5); + var result = await reducer.ReduceAsync([], CancellationToken.None); + Assert.Empty(result); + } + + [Fact] + public async Task ReduceAsync_PreservesFirstSystemMessage() + { + var reducer = new MessageCountingChatReducer(2); + + List messages = + [ + new ChatMessage(ChatRole.System, "You are a helpful assistant."), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi there!"), + new ChatMessage(ChatRole.User, "How are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks!"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("You are a helpful assistant.", m.Text); + }, + m => + { + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("How are you?", m.Text); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("I'm doing well, thanks!", m.Text); + }); + } + + [Fact] + public async Task ReduceAsync_OnlyFirstSystemMessageIsPreserved() + { + var reducer = new MessageCountingChatReducer(2); + + List messages = + [ + new ChatMessage(ChatRole.System, "First system message"), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.System, "Second system message"), + new ChatMessage(ChatRole.Assistant, "Hi"), + new ChatMessage(ChatRole.User, "How are you?"), + new ChatMessage(ChatRole.Assistant, "I'm fine!"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("First system message", m.Text); + }, + m => + { + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("How are you?", m.Text); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("I'm fine!", m.Text); + }); + + // Second system message should not be preserved separately + Assert.Equal(1, resultList.Count(m => m.Role == ChatRole.System)); + } + + [Fact] + public async Task ReduceAsync_IgnoresFunctionCallsAndResults() + { + var reducer = new MessageCountingChatReducer(2); + + List messages = + [ + new ChatMessage(ChatRole.User, "What's the weather?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["location"] = "Seattle" })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny, 72°F")]), + new ChatMessage(ChatRole.Assistant, "The weather in Seattle is sunny and 72°F."), + new ChatMessage(ChatRole.User, "Thanks!"), + new ChatMessage(ChatRole.Assistant, "You're welcome!"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("Thanks!", m.Text); + Assert.DoesNotContain(m.Contents, c => c is FunctionCallContent); + Assert.DoesNotContain(m.Contents, c => c is FunctionResultContent); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("You're welcome!", m.Text); + Assert.DoesNotContain(m.Contents, c => c is FunctionCallContent); + Assert.DoesNotContain(m.Contents, c => c is FunctionResultContent); + }); + } + + [Theory] + [InlineData(5, 3, 3)] // Less messages than target + [InlineData(5, 5, 5)] // Exactly at target + [InlineData(5, 8, 5)] // More messages than target + [InlineData(1, 10, 1)] // Only keep 1 message + public async Task ReduceAsync_RespectsTargetCount(int targetCount, int messageCount, int expectedCount) + { + var reducer = new MessageCountingChatReducer(targetCount); + + var messages = new List(); + for (int i = 0; i < messageCount; i++) + { + messages.Add(new ChatMessage(i % 2 == 0 ? ChatRole.User : ChatRole.Assistant, $"Message {i}")); + } + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Equal(expectedCount, resultList.Count); + + // Verify we kept the most recent messages + if (messageCount > targetCount) + { + var startIndex = messageCount - targetCount; + var expectedMessages = new Action[targetCount]; + for (int i = 0; i < targetCount; i++) + { + var expectedIndex = startIndex + i; + var expectedRole = expectedIndex % 2 == 0 ? ChatRole.User : ChatRole.Assistant; + expectedMessages[i] = m => + { + Assert.Equal(expectedRole, m.Role); + Assert.Equal($"Message {expectedIndex}", m.Text); + }; + } + + Assert.Collection(resultList, expectedMessages); + } + } + + [Fact] + public async Task ReduceAsync_HandlesOnlySystemMessage() + { + var reducer = new MessageCountingChatReducer(5); + + List messages = + [ + new ChatMessage(ChatRole.System, "System prompt"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("System prompt", m.Text); + }); + } + + [Fact] + public async Task ReduceAsync_HandlesOnlyFunctionMessages() + { + var reducer = new MessageCountingChatReducer(5); + + List messages = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "func", null)]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "result")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", "func", null)]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call2", "result")]), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + Assert.Empty(result); + } + + [Fact] + public async Task ReduceAsync_HandlesTargetCountOfOne() + { + var reducer = new MessageCountingChatReducer(1); + + List messages = + [ + new ChatMessage(ChatRole.System, "System"), + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.Assistant, "Second"), + new ChatMessage(ChatRole.User, "Third"), + new ChatMessage(ChatRole.Assistant, "Fourth"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("System", m.Text); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("Fourth", m.Text); + }); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs new file mode 100644 index 00000000000..985b097ece8 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs @@ -0,0 +1,269 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable S103 // Lines should not be too long + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class SummarizingChatReducerTests +{ + [Fact] + public void Constructor_ThrowsOnNullChatClient() + { + Assert.Throws(() => new SummarizingChatReducer(null!, targetCount: 5, threshold: 2)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-10)] + public void Constructor_ThrowsOnInvalidTargetCount(int targetCount) + { + using var chatClient = new TestChatClient(); + Assert.Throws(() => new SummarizingChatReducer(chatClient, targetCount, threshold: 2)); + } + + [Theory] + [InlineData(-1)] + [InlineData(-10)] + public void Constructor_ThrowsOnInvalidThresholdCount(int thresholdCount) + { + using var chatClient = new TestChatClient(); + Assert.Throws(() => new SummarizingChatReducer(chatClient, targetCount: 5, thresholdCount)); + } + + [Fact] + public async Task ReduceAsync_ThrowsOnNullMessages() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 5, threshold: 2); + await Assert.ThrowsAsync(() => reducer.ReduceAsync(null!, CancellationToken.None)); + } + + [Fact] + public async Task ReduceAsync_HandlesEmptyMessages() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 5, threshold: 2); + + var result = await reducer.ReduceAsync([], CancellationToken.None); + + Assert.Empty(result); + } + + [Fact] + public async Task ReduceAsync_PreservesSystemMessage() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 1, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.System, "You are a helpful assistant."), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi there!"), + new ChatMessage(ChatRole.User, "How are you?"), + ]; + + chatClient.GetResponseAsyncCallback = (_, _, _) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of conversation"))); + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + var resultList = result.ToList(); + Assert.Equal(3, resultList.Count); // System + Summary + 1 unsummarized + Assert.Equal(ChatRole.System, resultList[0].Role); + Assert.Equal("You are a helpful assistant.", resultList[0].Text); + } + + [Fact] + public async Task ReduceAsync_IgnoresFunctionCallsAndResults() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 3, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "What's the weather?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["location"] = "Seattle" })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny, 72°F")]), + new ChatMessage(ChatRole.Assistant, "The weather in Seattle is sunny and 72°F."), + new ChatMessage(ChatRole.User, "Thanks!"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + // Function calls/results should be ignored, which means there aren't enough messages to generate a summary. + var resultList = result.ToList(); + Assert.Equal(3, resultList.Count); // Function calls get removed in the summarized chat. + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is FunctionCallContent)); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is FunctionResultContent)); + } + + [Theory] + [InlineData(5, 0, 5, false)] // Exactly at target, no summarization + [InlineData(5, 0, 4, false)] // Below target, no summarization + [InlineData(5, 0, 6, true)] // Above target by 1, triggers summarization + [InlineData(5, 2, 7, false)] // At threshold boundary, no summarization + [InlineData(5, 2, 8, true)] // Above threshold, triggers summarization + public async Task ReduceAsync_RespectsTargetAndThresholdCounts(int targetCount, int thresholdCount, int messageCount, bool shouldSummarize) + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount, thresholdCount); + + var messages = new List(); + for (int i = 0; i < messageCount; i++) + { + messages.Add(new ChatMessage(i % 2 == 0 ? ChatRole.User : ChatRole.Assistant, $"Message {i}")); + } + + var summarizationCalled = false; + chatClient.GetResponseAsyncCallback = (_, _, _) => + { + summarizationCalled = true; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary"))); + }; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + Assert.Equal(shouldSummarize, summarizationCalled); + + if (shouldSummarize) + { + var resultList = result.ToList(); + Assert.Equal(targetCount + 1, resultList.Count); // Summary + target messages + Assert.StartsWith("Summary", resultList[0].Text, StringComparison.Ordinal); + } + else + { + Assert.Equal(messageCount, result.Count()); + } + } + + [Fact] + public async Task ReduceAsync_CancellationTokenIsRespected() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 1, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "Message 1"), + new ChatMessage(ChatRole.Assistant, "Response 1"), + new ChatMessage(ChatRole.User, "Message 2"), + ]; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + chatClient.GetResponseAsyncCallback = (_, _, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary"))); + }; + + await Assert.ThrowsAsync(() => + reducer.ReduceAsync(messages, cts.Token)); + } + + [Fact] + public async Task ReduceAsync_OnlyFirstSystemMessageIsPreserved() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 1, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.System, "First system message"), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.System, "Second system message"), + new ChatMessage(ChatRole.Assistant, "Hi"), + new ChatMessage(ChatRole.User, "How are you?"), + ]; + + chatClient.GetResponseAsyncCallback = (_, _, _) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary"))); + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + var resultList = result.ToList(); + Assert.Equal(ChatRole.System, resultList[0].Role); + Assert.Equal("First system message", resultList[0].Text); + + // Second system message should not be preserved separately + Assert.Equal(1, resultList.Count(m => m.Role == ChatRole.System)); + } + + [Fact] + public async Task CanHaveSummarizedConversation() + { + using var chatClientForSummarization = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClientForSummarization, targetCount: 2, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "Hi there! Can you tell me about golden retrievers?"), + new ChatMessage(ChatRole.Assistant, "Of course! Golden retrievers are known for their friendly and tolerant attitudes. They're great family pets and are very intelligent and easy to train."), + new ChatMessage(ChatRole.User, "What kind of exercise do they need?"), + new ChatMessage(ChatRole.Assistant, "Golden retrievers are quite active and need regular exercise. Daily walks, playtime, and activities like fetching or swimming are great for them."), + new ChatMessage(ChatRole.User, "Are they good with kids?"), + ]; + + chatClientForSummarization.GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(4, messages.Count()); // 3 messages to summarize + 1 system prompt + Assert.Collection(messages, + m => Assert.StartsWith("Hi there!", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Of course!", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("What kind of exercise", m.Text, StringComparison.Ordinal), + m => Assert.Equal(ChatRole.System, m.Role)); + const string Summary = """ + The user asked for information about golden retrievers. + The assistant explained that they have characteristics making them great family pets. + The user then asked what kind of exercise they need. + """; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, Summary))); + }; + + var reducedMessages = await reducer.ReduceAsync(messages, CancellationToken.None); + + Assert.Equal(3, reducedMessages.Count()); // 1 summary + 2 unsummarized messages + Assert.Collection(reducedMessages, + m => Assert.StartsWith("The user asked for information", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Golden retrievers are quite", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Are they good with kids", m.Text, StringComparison.Ordinal)); + + messages.Add(new ChatMessage(ChatRole.Assistant, "Golden retrievers get along well with kids! They're able to be playful and energetic while remaining gentle.")); + messages.Add(new ChatMessage(ChatRole.User, "Do they make good lap dogs?")); + + chatClientForSummarization.GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(4, messages.Count()); // 1 summary message, 2 unsummarized message, 1 system prompt + Assert.Collection(messages, + m => Assert.StartsWith("The user asked", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Golden retrievers are quite active", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Are they good with kids", m.Text, StringComparison.Ordinal), + m => Assert.Equal(ChatRole.System, m.Role)); + const string Summary = """ + The user and assistant are discussing characteristics of golden retrievers. + The user asked what kind of exercise they need, and the assitant explained that golden retrievers + need frequent exercise. The user then asked about whether they're good around kids. + """; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, Summary))); + }; + + reducedMessages = await reducer.ReduceAsync(messages, CancellationToken.None); + Assert.Equal(3, reducedMessages.Count()); // 1 summary + 2 unsummarized messages + Assert.Collection(reducedMessages, + m => Assert.StartsWith("The user and assistant are discussing", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Golden retrievers get along", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Do they make good lap dogs", m.Text, StringComparison.Ordinal)); + } +}