From 2d7ad3a50441dca3e5d90be59b82859410187d90 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 2 Oct 2024 21:58:16 +0000 Subject: [PATCH 001/190] Merged PR 43287: Getting ready for 8.10 release Getting ready for 8.10 release ---- #### AI description (iteration 1) #### PR Classification Release preparation #### PR Summary This pull request updates dependencies and configurations in preparation for the 8.10 release. - `/eng/Version.Details.xml`: Updated various dependencies to newer versions. - `/eng/Versions.props`: Synchronized dependency versions with the latest updates. - `/azure-pipelines.yml`: Removed the `codecoverage` stage. - `/eng/pipelines/templates/BuildAndTest.yml`: Added steps to set up private feed credentials for both Windows and non-Windows agents. --- Directory.Build.props | 5 ++ NuGet.config | 46 ++++++------ azure-pipelines.yml | 48 +------------ eng/Version.Details.xml | 92 ++++++++++++------------ eng/Versions.props | 53 +++++++------- eng/pipelines/templates/BuildAndTest.yml | 18 +++++ 6 files changed, 118 insertions(+), 144 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index cea28e22ade..850685ca2d7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -34,6 +34,11 @@ $(NetCoreTargetFrameworks) + + + $(NoWarn);NU1507 + + false latest diff --git a/NuGet.config b/NuGet.config index 9e48c557166..02f1d3194f2 100644 --- a/NuGet.config +++ b/NuGet.config @@ -4,10 +4,18 @@ - - + + + + + + + + + + @@ -24,33 +32,18 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + @@ -58,6 +51,7 @@ + diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c8ab2149b97..3da5b47240a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -141,7 +141,7 @@ extends: parameters: enableMicrobuild: true enableTelemetry: true - enableSourceIndex: true + enableSourceIndex: false runAsPublic: ${{ variables['runAsPublic'] }} # Publish build logs enablePublishBuildArtifacts: true @@ -218,51 +218,6 @@ extends: isWindows: false warnAsError: 0 - # ---------------------------------------------------------------- - # This stage performs quality gates enforcements - # ---------------------------------------------------------------- - - stage: codecoverage - displayName: CodeCoverage - dependsOn: - - build - condition: and(succeeded('build'), ne(variables['SkipQualityGates'], 'true')) - variables: - - template: /eng/common/templates-official/variables/pool-providers.yml@self - jobs: - - template: /eng/common/templates-official/jobs/jobs.yml@self - parameters: - enableMicrobuild: true - enableTelemetry: true - runAsPublic: ${{ variables['runAsPublic'] }} - workspace: - clean: all - - # ---------------------------------------------------------------- - # This stage downloads the code coverage reports from the build jobs, - # merges those and validates the combined test coverage. - # ---------------------------------------------------------------- - jobs: - - job: CodeCoverageReport - timeoutInMinutes: 180 - - pool: - name: NetCore1ESPool-Internal - image: 1es-mariner-2 - os: linux - - preSteps: - - checkout: self - clean: true - persistCredentials: true - fetchDepth: 1 - - steps: - - script: $(Build.SourcesDirectory)/build.sh --ci --restore - displayName: Init toolset - - - template: /eng/pipelines/templates/VerifyCoverageReport.yml - - # ---------------------------------------------------------------- # This stage only performs a build treating warnings as errors # to detect any kind of code style violations @@ -318,7 +273,6 @@ extends: parameters: validateDependsOn: - build - - codecoverage - correctness publishingInfraVersion: 3 enableSymbolValidation: false diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 674eca8d91e..d7dfa636373 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -16,53 +16,53 @@ https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 2aade6beb02ea367fd97c4070a4198802fe61c03 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 5535e31a712343a63f5d7d796cd874e563e5ac14 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9f4b1f5d664afdfc80e1508ab7ed099dff210fbd + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9f4b1f5d664afdfc80e1508ab7ed099dff210fbd + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime @@ -76,65 +76,65 @@ https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 5535e31a712343a63f5d7d796cd874e563e5ac14 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 2d7eea252964e69be94cb9c847b371b23e4dd470 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 5535e31a712343a63f5d7d796cd874e563e5ac14 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 2aade6beb02ea367fd97c4070a4198802fe61c03 + 81cabf2857a01351e5ab578947c7403a5b128ad1 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 5535e31a712343a63f5d7d796cd874e563e5ac14 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 5535e31a712343a63f5d7d796cd874e563e5ac14 + 81cabf2857a01351e5ab578947c7403a5b128ad1 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 954f61dd38b33caa2b736c73530bd5a294174437 + c2a442982e736e17ae6bcadbfd8ccba278ee1be6 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 954f61dd38b33caa2b736c73530bd5a294174437 + c2a442982e736e17ae6bcadbfd8ccba278ee1be6 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 954f61dd38b33caa2b736c73530bd5a294174437 + c2a442982e736e17ae6bcadbfd8ccba278ee1be6 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 954f61dd38b33caa2b736c73530bd5a294174437 + c2a442982e736e17ae6bcadbfd8ccba278ee1be6 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 954f61dd38b33caa2b736c73530bd5a294174437 + c2a442982e736e17ae6bcadbfd8ccba278ee1be6 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 954f61dd38b33caa2b736c73530bd5a294174437 + c2a442982e736e17ae6bcadbfd8ccba278ee1be6 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 954f61dd38b33caa2b736c73530bd5a294174437 + c2a442982e736e17ae6bcadbfd8ccba278ee1be6 diff --git a/eng/Versions.props b/eng/Versions.props index 314b495eeb7..2d9a7174db0 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -3,7 +3,7 @@ 8 10 0 - preview + rtm $(MajorVersion).$(MinorVersion).$(PatchVersion) @@ -11,8 +11,11 @@ $(MajorVersion).$(MinorVersion).0.0 - + release true @@ -34,43 +37,43 @@ 8.0.0 8.0.2 8.0.0 - 8.0.0 + 8.0.1 8.0.0 - 8.0.1 - 8.0.0 - 8.0.0 - 8.0.0 - 8.0.0 - 8.0.0 - 8.0.1 - 8.0.0 - 8.0.0 - 8.0.0 + 8.0.2 + 8.0.1 + 8.0.1 + 8.0.1 + 8.0.1 + 8.0.1 + 8.0.2 + 8.0.1 + 8.0.1 + 8.0.1 8.0.0 8.0.0 8.0.2 8.0.0 8.0.0 - 8.0.0 + 8.0.1 8.0.1 - 8.0.0 + 8.0.1 8.0.0 8.0.0 - 8.0.0 - 8.0.1 + 8.0.1 + 8.0.2 8.0.0 8.0.3 - 8.0.0 + 8.0.1 - 8.0.8 - 8.0.8 - 8.0.8 - 8.0.8 + 8.0.10 + 8.0.10 + 8.0.10 + 8.0.10 8.0.5 - 8.0.8 + 8.0.10 8.0.5 - 8.0.8 - 8.0.8 + 8.0.10 + 8.0.10 + + $(NoWarn);NU1507 + + false latest diff --git a/NuGet.config b/NuGet.config index f91233ccab5..aba191afbbc 100644 --- a/NuGet.config +++ b/NuGet.config @@ -3,6 +3,7 @@ + @@ -15,41 +16,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 211058cf56a..f674e637cea 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -143,7 +143,7 @@ extends: parameters: enableMicrobuild: true enableTelemetry: true - enableSourceIndex: true + enableSourceIndex: false runAsPublic: ${{ variables['runAsPublic'] }} # Publish build logs enablePublishBuildArtifacts: true @@ -220,51 +220,6 @@ extends: isWindows: false warnAsError: 0 - # ---------------------------------------------------------------- - # This stage performs quality gates enforcements - # ---------------------------------------------------------------- - - stage: codecoverage - displayName: CodeCoverage - dependsOn: - - build - condition: and(succeeded('build'), ne(variables['SkipQualityGates'], 'true')) - variables: - - template: /eng/common/templates-official/variables/pool-providers.yml@self - jobs: - - template: /eng/common/templates-official/jobs/jobs.yml@self - parameters: - enableMicrobuild: true - enableTelemetry: true - runAsPublic: ${{ variables['runAsPublic'] }} - workspace: - clean: all - - # ---------------------------------------------------------------- - # This stage downloads the code coverage reports from the build jobs, - # merges those and validates the combined test coverage. - # ---------------------------------------------------------------- - jobs: - - job: CodeCoverageReport - timeoutInMinutes: 180 - - pool: - name: NetCore1ESPool-Internal - image: 1es-mariner-2 - os: linux - - preSteps: - - checkout: self - clean: true - persistCredentials: true - fetchDepth: 1 - - steps: - - script: $(Build.SourcesDirectory)/build.sh --ci --restore - displayName: Init toolset - - - template: /eng/pipelines/templates/VerifyCoverageReport.yml - - # ---------------------------------------------------------------- # This stage only performs a build treating warnings as errors # to detect any kind of code style violations @@ -320,7 +275,6 @@ extends: parameters: validateDependsOn: - build - - codecoverage - correctness publishingInfraVersion: 3 enableSymbolValidation: false diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index e4dcd0226ff..e2498d1c564 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,172 +1,172 @@ - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/runtime - 0d44aea3696bab80b11a12c6bdfdbf8de9c4e815 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 - - https://github.com/dotnet/aspnetcore - 9a34a6e3c7975f41300bd2550a089a85810cafd1 + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + c70204ae3c91d2b48fa6d9b92b62265f368421b4 - - https://github.com/dotnet/aspnetcore - 9a34a6e3c7975f41300bd2550a089a85810cafd1 + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + c70204ae3c91d2b48fa6d9b92b62265f368421b4 - - https://github.com/dotnet/aspnetcore - 9a34a6e3c7975f41300bd2550a089a85810cafd1 + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + c70204ae3c91d2b48fa6d9b92b62265f368421b4 - - https://github.com/dotnet/aspnetcore - 9a34a6e3c7975f41300bd2550a089a85810cafd1 + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + c70204ae3c91d2b48fa6d9b92b62265f368421b4 - - https://github.com/dotnet/aspnetcore - 9a34a6e3c7975f41300bd2550a089a85810cafd1 + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + c70204ae3c91d2b48fa6d9b92b62265f368421b4 - - https://github.com/dotnet/aspnetcore - 9a34a6e3c7975f41300bd2550a089a85810cafd1 + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + c70204ae3c91d2b48fa6d9b92b62265f368421b4 - - https://github.com/dotnet/aspnetcore - 9a34a6e3c7975f41300bd2550a089a85810cafd1 + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + c70204ae3c91d2b48fa6d9b92b62265f368421b4 - - https://github.com/dotnet/aspnetcore - 9a34a6e3c7975f41300bd2550a089a85810cafd1 + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + c70204ae3c91d2b48fa6d9b92b62265f368421b4 - - https://github.com/dotnet/aspnetcore - 9a34a6e3c7975f41300bd2550a089a85810cafd1 + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + c70204ae3c91d2b48fa6d9b92b62265f368421b4 diff --git a/eng/Versions.props b/eng/Versions.props index e5209b4f90b..186e160151a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -28,48 +28,48 @@ --> - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 - 9.0.0-rtm.24476.4 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 + 9.0.0-rc.2.24474.3 + 9.0.0-rc.2.24474.3 + 9.0.0-rc.2.24474.3 + 9.0.0-rc.2.24474.3 + 9.0.0-rc.2.24474.3 + 9.0.0-rc.2.24474.3 + 9.0.0-rc.2.24474.3 + 9.0.0-rc.2.24474.3 + 9.0.0-rc.2.24474.3 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 - 9.0.0-rtm.24477.5 + 9.0.0-rtm.24501.7 + 9.0.0-rtm.24501.7 + 9.0.0-rtm.24501.7 + 9.0.0-rtm.24501.7 + 9.0.0-rtm.24501.7 + 9.0.0-rtm.24501.7 + 9.0.0-rtm.24501.7 + 9.0.0-rtm.24501.7 + 9.0.0-rtm.24501.7 - 9.0.0-rtm.24501.7 - 9.0.0-rtm.24501.7 - 9.0.0-rtm.24501.7 - 9.0.0-rtm.24501.7 - 9.0.0-rtm.24501.7 - 9.0.0-rtm.24501.7 - 9.0.0-rtm.24501.7 - 9.0.0-rtm.24501.7 - 9.0.0-rtm.24501.7 + 9.0.0-rtm.24507.7 + 9.0.0-rtm.24507.7 + 9.0.0-rtm.24507.7 + 9.0.0-rtm.24507.7 + 9.0.0-rtm.24507.7 + 9.0.0-rtm.24507.7 + 9.0.0-rtm.24507.7 + 9.0.0-rtm.24507.7 + 9.0.0-rtm.24507.7 + true + $(NoWarn);LA0003 + + - + diff --git a/eng/MSBuild/Shared.props b/eng/MSBuild/Shared.props index a68b0e4298f..7c5ac8424e0 100644 --- a/eng/MSBuild/Shared.props +++ b/eng/MSBuild/Shared.props @@ -1,4 +1,8 @@ + + + + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index e2498d1c564..3b65583f912 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,5 +1,9 @@ + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 990ebf52fc408ca45929fd176d2740675a67fab8 @@ -112,6 +116,18 @@ https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 990ebf52fc408ca45929fd176d2740675a67fab8 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 990ebf52fc408ca45929fd176d2740675a67fab8 diff --git a/eng/Versions.props b/eng/Versions.props index 186e160151a..68ddd98d120 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -28,6 +28,7 @@ --> + 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 @@ -55,11 +56,14 @@ 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 9.0.0-rc.2.24474.3 9.0.0-rc.2.24474.3 diff --git a/eng/packages/General.props b/eng/packages/General.props index b7e3259930f..ce9c0579971 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -1,7 +1,9 @@ + + @@ -33,6 +35,7 @@ + @@ -47,9 +50,12 @@ + + + diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 9e9fefae39d..2bde3b34e05 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -2,17 +2,21 @@ + + + + diff --git a/src/LegacySupport/CompilerFeatureRequiredAttribute/CompilerFeatureRequiredAttribute.cs b/src/LegacySupport/CompilerFeatureRequiredAttribute/CompilerFeatureRequiredAttribute.cs new file mode 100644 index 00000000000..b979931673c --- /dev/null +++ b/src/LegacySupport/CompilerFeatureRequiredAttribute/CompilerFeatureRequiredAttribute.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable SA1623 // Property summary documentation should match accessors + +namespace System.Runtime.CompilerServices; + +/// +/// Indicates that compiler support for a particular feature is required for the location where this attribute is applied. +/// +[AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] +internal sealed class CompilerFeatureRequiredAttribute : Attribute +{ + public CompilerFeatureRequiredAttribute(string featureName) + { + FeatureName = featureName; + } + + /// + /// The name of the compiler feature. + /// + public string FeatureName { get; } + + /// + /// If true, the compiler can choose to allow access to the location where this attribute is applied if it does not understand . + /// + public bool IsOptional { get; init; } + + /// + /// The used for the ref structs C# feature. + /// + public const string RefStructs = nameof(RefStructs); + + /// + /// The used for the required members C# feature. + /// + public const string RequiredMembers = nameof(RequiredMembers); +} diff --git a/src/LegacySupport/CompilerFeatureRequiredAttribute/README.md b/src/LegacySupport/CompilerFeatureRequiredAttribute/README.md new file mode 100644 index 00000000000..c30799eef0b --- /dev/null +++ b/src/LegacySupport/CompilerFeatureRequiredAttribute/README.md @@ -0,0 +1,9 @@ +Enables use of C# required members on older frameworks. + +To use this source in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/src/LegacySupport/RequiredMemberAttribute/README.md b/src/LegacySupport/RequiredMemberAttribute/README.md new file mode 100644 index 00000000000..da8c9bc98ce --- /dev/null +++ b/src/LegacySupport/RequiredMemberAttribute/README.md @@ -0,0 +1,9 @@ +Enables use of C# required members on older frameworks. + +To use this source in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/src/LegacySupport/RequiredMemberAttribute/RequiredMemberAttribute.cs b/src/LegacySupport/RequiredMemberAttribute/RequiredMemberAttribute.cs new file mode 100644 index 00000000000..a83785b9655 --- /dev/null +++ b/src/LegacySupport/RequiredMemberAttribute/RequiredMemberAttribute.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Runtime.CompilerServices; + +/// Specifies that a type has required members or that a member is required. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] +[EditorBrowsable(EditorBrowsableState.Never)] +internal sealed class RequiredMemberAttribute : Attribute; diff --git a/src/LegacySupport/TrimAttributes/RequiresDynamicCodeAttribute.cs b/src/LegacySupport/TrimAttributes/RequiresDynamicCodeAttribute.cs new file mode 100644 index 00000000000..072701f1a46 --- /dev/null +++ b/src/LegacySupport/TrimAttributes/RequiresDynamicCodeAttribute.cs @@ -0,0 +1,49 @@ +// 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 IDE0079 +#pragma warning disable SA1101 +#pragma warning disable SA1116 +#pragma warning disable SA1117 +#pragma warning disable SA1512 +#pragma warning disable SA1623 +#pragma warning disable SA1642 +#pragma warning disable S3903 +#pragma warning disable S3996 + +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Indicates that the specified method requires the ability to generate new code at runtime, +/// for example through . +/// +/// +/// This allows tools to understand which methods are unsafe to call when compiling ahead of time. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Class, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class RequiresDynamicCodeAttribute : Attribute +{ + /// + /// Initializes a new instance of the class + /// with the specified message. + /// + /// + /// A message that contains information about the usage of dynamic code. + /// + public RequiresDynamicCodeAttribute(string message) + { + Message = message; + } + + /// + /// Gets a message that contains information about the usage of dynamic code. + /// + public string Message { get; } + + /// + /// Gets or sets an optional URL that contains more information about the method, + /// why it requires dynamic code, and what options a consumer has to deal with it. + /// + public string? Url { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs new file mode 100644 index 00000000000..0cdcd60e63e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs @@ -0,0 +1,13 @@ +// 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; + +/// Represents a tool that may be specified to an AI service. +public class AITool +{ + /// Initializes a new instance of the class. + protected AITool() + { + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs new file mode 100644 index 00000000000..5ffc76260d9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs @@ -0,0 +1,101 @@ +// 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; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Extensions.AI; + +/// Provides a dictionary used as the AdditionalProperties dictionary on Microsoft.Extensions.AI objects. +public sealed class AdditionalPropertiesDictionary : IDictionary, IReadOnlyDictionary +{ + /// The underlying dictionary. + private readonly Dictionary _dictionary; + + /// Initializes a new instance of the class. + public AdditionalPropertiesDictionary() + { + _dictionary = new(StringComparer.OrdinalIgnoreCase); + } + + /// Initializes a new instance of the class. + public AdditionalPropertiesDictionary(IDictionary dictionary) + { + _dictionary = new(dictionary, StringComparer.OrdinalIgnoreCase); + } + + /// Initializes a new instance of the class. + public AdditionalPropertiesDictionary(IEnumerable> collection) + { +#if NET + _dictionary = new(collection, StringComparer.OrdinalIgnoreCase); +#else + _dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in collection) + { + _dictionary.Add(item.Key, item.Value); + } +#endif + } + + /// + public object? this[string key] + { + get => _dictionary[key]; + set => _dictionary[key] = value; + } + + /// + public ICollection Keys => _dictionary.Keys; + + /// + public ICollection Values => _dictionary.Values; + + /// + public int Count => _dictionary.Count; + + /// + bool ICollection>.IsReadOnly => false; + + /// + IEnumerable IReadOnlyDictionary.Keys => _dictionary.Keys; + + /// + IEnumerable IReadOnlyDictionary.Values => _dictionary.Values; + + /// + public void Add(string key, object? value) => _dictionary.Add(key, value); + + /// + void ICollection>.Add(KeyValuePair item) => ((ICollection>)_dictionary).Add(item); + + /// + public void Clear() => _dictionary.Clear(); + + /// + bool ICollection>.Contains(KeyValuePair item) => _dictionary.Contains(item); + + /// + public bool ContainsKey(string key) => _dictionary.ContainsKey(key); + + /// + void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) => + ((ICollection>)_dictionary).CopyTo(array, arrayIndex); + + /// + public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); + + /// + public bool Remove(string key) => _dictionary.Remove(key); + + /// + bool ICollection>.Remove(KeyValuePair item) => ((ICollection>)_dictionary).Remove(item); + + /// + public bool TryGetValue(string key, out object? value) => _dictionary.TryGetValue(key, out value); + + /// + IEnumerator IEnumerable.GetEnumerator() => _dictionary.GetEnumerator(); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/AutoChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/AutoChatToolMode.cs new file mode 100644 index 00000000000..d6307477296 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/AutoChatToolMode.cs @@ -0,0 +1,28 @@ +// 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; + +namespace Microsoft.Extensions.AI; + +/// +/// Indicates that an is free to select any of the available tools, or none at all. +/// +/// +/// Use to get an instance of . +/// +[DebuggerDisplay("Auto")] +public sealed class AutoChatToolMode : ChatToolMode +{ + /// Initializes a new instance of the class. + /// Use to get an instance of . + public AutoChatToolMode() + { + } // must exist in support of polymorphic deserialization of a ChatToolMode + + /// + public override bool Equals(object? obj) => obj is AutoChatToolMode; + + /// + public override int GetHashCode() => typeof(AutoChatToolMode).GetHashCode(); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs new file mode 100644 index 00000000000..944283ccd88 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides a collection of static methods for extending instances. +public static class ChatClientExtensions +{ + /// Sends a user chat text message to the model and returns the response messages. + /// The chat client. + /// The text content for the chat message to send. + /// The chat options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// The response messages generated by the client. + public static Task CompleteAsync( + this IChatClient client, + string chatMessage, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(client); + _ = Throw.IfNull(chatMessage); + + return client.CompleteAsync([new ChatMessage(ChatRole.User, chatMessage)], options, cancellationToken); + } + + /// Sends a user chat text message to the model and streams the response messages. + /// The chat client. + /// The text content for the chat message to send. + /// The chat options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// The response messages generated by the client. + public static IAsyncEnumerable CompleteStreamingAsync( + this IChatClient client, + string chatMessage, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(client); + _ = Throw.IfNull(chatMessage); + + return client.CompleteStreamingAsync([new ChatMessage(ChatRole.User, chatMessage)], options, cancellationToken); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs new file mode 100644 index 00000000000..b98455daf2a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs @@ -0,0 +1,31 @@ +// 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; + +/// Provides metadata about an . +public class ChatClientMetadata +{ + /// Initializes a new instance of the class. + /// The name of the chat completion provider, if applicable. + /// The URL for accessing the chat completion provider, if applicable. + /// The id of the chat completion model used, if applicable. + public ChatClientMetadata(string? providerName = null, Uri? providerUri = null, string? modelId = null) + { + ModelId = modelId; + ProviderName = providerName; + ProviderUri = providerUri; + } + + /// Gets the name of the chat completion provider. + public string? ProviderName { get; } + + /// Gets the URL for accessing the chat completion provider. + public Uri? ProviderUri { get; } + + /// Gets the id of the model used by this chat completion provider. + /// This may be null if either the name is unknown or there are multiple possible models associated with this instance. + public string? ModelId { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs new file mode 100644 index 00000000000..2a9237d9b5a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents the result of a chat completion request. +public class ChatCompletion +{ + /// The list of choices in the completion. + private IList _choices; + + /// Initializes a new instance of the class. + /// The list of choices in the completion, one message per choice. + [JsonConstructor] + public ChatCompletion(IList choices) + { + _choices = Throw.IfNull(choices); + } + + /// Initializes a new instance of the class. + /// The chat message representing the singular choice in the completion. + public ChatCompletion(ChatMessage message) + { + _ = Throw.IfNull(message); + _choices = [message]; + } + + /// Gets or sets the list of chat completion choices. + public IList Choices + { + get => _choices; + set => _choices = Throw.IfNull(value); + } + + /// Gets the chat completion message. + /// + /// If there are multiple choices, this property returns the first choice. + /// If is empty, this will throw. Use to access all choices directly."/>. + /// + public ChatMessage Message + { + get + { + var choices = Choices; + if (choices.Count == 0) + { + throw new InvalidOperationException($"The {nameof(ChatCompletion)} instance does not contain any {nameof(ChatMessage)} choices."); + } + + return choices[0]; + } + } + + /// Gets or sets the ID of the chat completion. + public string? CompletionId { get; set; } + + /// Gets or sets the model ID using in the creation of the chat completion. + public string? ModelId { get; set; } + + /// Gets or sets a timestamp for the chat completion. + public DateTimeOffset? CreatedAt { get; set; } + + /// Gets or sets the reason for the chat completion. + public ChatFinishReason? FinishReason { get; set; } + + /// Gets or sets usage details for the chat completion. + public UsageDetails? Usage { get; set; } + + /// Gets or sets the raw representation of the chat completion from an underlying implementation. + /// + /// If a is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// Gets or sets any additional properties associated with the chat completion. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// + public override string ToString() => + Choices is { Count: > 0 } choices ? string.Join(Environment.NewLine, choices) : string.Empty; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs new file mode 100644 index 00000000000..08a5630c51b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.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.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents the reason a chat response completed. +[JsonConverter(typeof(Converter))] +public readonly struct ChatFinishReason : IEquatable +{ + /// The finish reason value. If null because `default(ChatFinishReason)` was used, the instance will behave like . + private readonly string? _value; + + /// Initializes a new instance of the struct with a string that describes the reason. + /// The reason value. + /// is null. + /// is empty or composed entirely of whitespace. + [JsonConstructor] + public ChatFinishReason(string value) + { + _value = Throw.IfNullOrWhitespace(value); + } + + /// Gets the finish reason value. + public string Value => _value ?? Stop.Value; + + /// + public override bool Equals([NotNullWhen(true)] object? obj) => obj is ChatFinishReason other && Equals(other); + + /// + public bool Equals(ChatFinishReason other) => StringComparer.OrdinalIgnoreCase.Equals(Value, other.Value); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + /// Compares two instances. + /// + /// Left argument of the comparison. + /// Right argument of the comparison. + /// when equal, otherwise. + public static bool operator ==(ChatFinishReason left, ChatFinishReason right) + { + return left.Equals(right); + } + + /// + /// Compares two instances. + /// + /// Left argument of the comparison. + /// Right argument of the comparison. + /// when not equal, otherwise. + public static bool operator !=(ChatFinishReason left, ChatFinishReason right) + { + return !(left == right); + } + + /// Gets the of the finish reason. + /// The of the finish reason. + public override string ToString() => Value; + + /// Gets a representing the model encountering a natural stop point or provided stop sequence. + public static ChatFinishReason Stop { get; } = new("stop"); + + /// Gets a representing the model reaching the maximum length allowed for the request and/or response (typically in terms of tokens). + public static ChatFinishReason Length { get; } = new("length"); + + /// Gets a representing the model requesting the use of a tool that was defined in the request. + public static ChatFinishReason ToolCalls { get; } = new("tool_calls"); + + /// Gets a representing the model filtering content, whether for safety, prohibited content, sensitive content, or other such issues. + public static ChatFinishReason ContentFilter { get; } = new("content_filter"); + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ChatFinishReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new(reader.GetString()!); + + /// + public override void Write(Utf8JsonWriter writer, ChatFinishReason value, JsonSerializerOptions options) => + Throw.IfNull(writer).WriteStringValue(value.Value); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs new file mode 100644 index 00000000000..4fdb138b615 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs @@ -0,0 +1,99 @@ +// 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.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a chat message used by an . +public class ChatMessage +{ + private IList? _contents; + private string? _authorName; + + /// Initializes a new instance of the class. + [JsonConstructor] + public ChatMessage() + { + } + + /// Initializes a new instance of the class. + /// Role of the author of the message. + /// Content of the message. + public ChatMessage(ChatRole role, string? content) + : this(role, content is null ? [] : [new TextContent(content)]) + { + } + + /// Initializes a new instance of the class. + /// Role of the author of the message. + /// The contents for this message. + public ChatMessage( + ChatRole role, + IList contents) + { + Role = role; + _contents = Throw.IfNull(contents); + } + + /// Gets or sets the name of the author of the message. + public string? AuthorName + { + get => _authorName; + set => _authorName = string.IsNullOrWhiteSpace(value) ? null : value; + } + + /// Gets or sets the role of the author of the message. + public ChatRole Role { get; set; } = ChatRole.User; + + /// + /// Gets or sets the text of the first instance in . + /// + /// + /// If there is no instance in , then the getter returns , + /// and the setter will add a new instance with the provided value. + /// + [JsonIgnore] + public string? Text + { + get => Contents.OfType().FirstOrDefault()?.Text; + set + { + if (Contents.OfType().FirstOrDefault() is { } textContent) + { + textContent.Text = value; + } + else if (value is not null) + { + Contents.Add(new TextContent(value)); + } + } + } + + /// Gets or sets the chat message content items. + [AllowNull] + public IList Contents + { + get => _contents ??= []; + set => _contents = value; + } + + /// Gets or sets the raw representation of the chat message from an underlying implementation. + /// + /// If a is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// Gets or sets any additional properties associated with the message. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// + public override string ToString() => Text ?? string.Empty; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs new file mode 100644 index 00000000000..21224454000 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.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.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Represents the options for a chat request. +public class ChatOptions +{ + /// Gets or sets the temperature for generating chat responses. + public float? Temperature { get; set; } + + /// Gets or sets the maximum number of tokens in the generated chat response. + public int? MaxOutputTokens { get; set; } + + /// Gets or sets the "nucleus sampling" factor (or "top p") for generating chat responses. + public float? TopP { get; set; } + + /// Gets or sets the frequency penalty for generating chat responses. + public float? FrequencyPenalty { get; set; } + + /// Gets or sets the presence penalty for generating chat responses. + public float? PresencePenalty { get; set; } + + /// + /// Gets or sets the response format for the chat request. + /// + /// + /// If null, no response format is specified and the client will use its default. + /// This may be set to to specify that the response should be unstructured text, + /// to to specify that the response should be structured JSON data, or + /// an instance of constructed with a specific JSON schema to request that the + /// response be structured JSON data according to that schema. It is up to the client implementation if or how + /// to honor the request. If the client implementation doesn't recognize the specific kind of , + /// it may be ignored. + /// + public ChatResponseFormat? ResponseFormat { get; set; } + + /// Gets or sets the model ID for the chat request. + public string? ModelId { get; set; } + + /// Gets or sets the stop sequences for generating chat responses. + public IList? StopSequences { get; set; } + + /// Gets or sets the tool mode for the chat request. + public ChatToolMode ToolMode { get; set; } = ChatToolMode.Auto; + + /// Gets or sets the list of tools to include with a chat request. + [JsonIgnore] + public IList? Tools { get; set; } + + /// Gets or sets any additional properties associated with the options. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Produces a clone of the current instance. + /// A clone of the current instance. + /// + /// The clone will have the same values for all properties as the original instance. Any collections, like , + /// , and , are shallow-cloned, meaning a new collection instance is created, + /// but any references contained by the collections are shared with the original. + /// + public virtual ChatOptions Clone() + { + ChatOptions options = new() + { + Temperature = Temperature, + MaxOutputTokens = MaxOutputTokens, + TopP = TopP, + FrequencyPenalty = FrequencyPenalty, + PresencePenalty = PresencePenalty, + ResponseFormat = ResponseFormat, + ModelId = ModelId, + ToolMode = ToolMode, + }; + + if (StopSequences is not null) + { + options.StopSequences = new List(StopSequences); + } + + if (Tools is not null) + { + options.Tools = new List(Tools); + } + + if (AdditionalProperties is not null) + { + options.AdditionalProperties = new(AdditionalProperties); + } + + return options; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs new file mode 100644 index 00000000000..6f1574fe400 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents the response format that is desired by the caller. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(ChatResponseFormatText), typeDiscriminator: "text")] +[JsonDerivedType(typeof(ChatResponseFormatJson), typeDiscriminator: "json")] +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable +public class ChatResponseFormat +#pragma warning restore CA1052 +{ + /// Initializes a new instance of the class. + /// Prevents external instantiation. Close the inheritance hierarchy for now until we have good reason to open it. + private protected ChatResponseFormat() + { + } + + /// Gets a singleton instance representing unstructured textual data. + public static ChatResponseFormatText Text { get; } = new(); + + /// Gets a singleton instance representing structured JSON data but without any particular schema. + public static ChatResponseFormatJson Json { get; } = new(schema: null); + + /// Creates a representing structured JSON data with the specified schema. + /// The JSON schema. + /// An optional name of the schema, e.g. if the schema represents a particular class, this could be the name of the class. + /// An optional description of the schema. + /// The instance. + public static ChatResponseFormatJson ForJsonSchema( + [StringSyntax(StringSyntaxAttribute.Json)] string schema, string? schemaName = null, string? schemaDescription = null) => + new(Throw.IfNull(schema), + schemaName, + schemaDescription); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs new file mode 100644 index 00000000000..e26c769ca62 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a response format for structured JSON data. +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class ChatResponseFormatJson : ChatResponseFormat +{ + /// Initializes a new instance of the class with the specified schema. + /// The schema to associate with the JSON response. + /// A name for the schema. + /// A description of the schema. + [JsonConstructor] + public ChatResponseFormatJson( + [StringSyntax(StringSyntaxAttribute.Json)] string? schema, string? schemaName = null, string? schemaDescription = null) + { + if (schema is null && (schemaName is not null || schemaDescription is not null)) + { + Throw.ArgumentException( + schemaName is not null ? nameof(schemaName) : nameof(schemaDescription), + "Schema name and description can only be specified if a schema is provided."); + } + + Schema = schema; + SchemaName = schemaName; + SchemaDescription = schemaDescription; + } + + /// Gets the JSON schema associated with the response, or null if there is none. + public string? Schema { get; } + + /// Gets a name for the schema. + public string? SchemaName { get; } + + /// Gets a description of the schema. + public string? SchemaDescription { get; } + + /// + public override bool Equals(object? obj) => + obj is ChatResponseFormatJson other && + Schema == other.Schema && + SchemaName == other.SchemaName && + SchemaDescription == other.SchemaDescription; + + /// + public override int GetHashCode() => + Schema?.GetHashCode(StringComparison.Ordinal) ?? + typeof(ChatResponseFormatJson).GetHashCode(); + + /// Gets a string representing this instance to display in the debugger. + private string DebuggerDisplay => Schema ?? "JSON"; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatText.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatText.cs new file mode 100644 index 00000000000..71cd8b2877d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatText.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a response format with no constraints around the format. +/// +/// Use to get an instance of . +/// +[DebuggerDisplay("Text")] +public sealed class ChatResponseFormatText : ChatResponseFormat +{ + /// Initializes a new instance of the class. + /// Use to get an instance of . + public ChatResponseFormatText() + { + // must exist in support of polymorphic deserialization of a ChatResponseFormat + } + + /// + public override bool Equals(object? obj) => obj is ChatResponseFormatText; + + /// + public override int GetHashCode() => typeof(ChatResponseFormatText).GetHashCode(); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs new file mode 100644 index 00000000000..f898bb58892 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Describes the intended purpose of a message within a chat completion interaction. +/// +[JsonConverter(typeof(Converter))] +public readonly struct ChatRole : IEquatable +{ + /// Gets the role that instructs or sets the behavior of the assistant. + public static ChatRole System { get; } = new("system"); + + /// Gets the role that provides responses to system-instructed, user-prompted input. + public static ChatRole Assistant { get; } = new("assistant"); + + /// Gets the role that provides input for chat completions. + public static ChatRole User { get; } = new("user"); + + /// Gets the role that provides additional information and references for chat completions. + public static ChatRole Tool { get; } = new("tool"); + + /// + /// Gets the value associated with this . + /// + /// + /// The value is what will be serialized into the "role" message field of the Chat Message format. + /// + public string Value { get; } + + /// + /// Initializes a new instance of the struct with the provided value. + /// + /// The value to associate with this . + [JsonConstructor] + public ChatRole(string value) + { + Value = Throw.IfNullOrWhitespace(value); + } + + /// + /// Returns a value indicating whether two instances are equivalent, as determined by a + /// case-insensitive comparison of their values. + /// + /// the first instance to compare. + /// the second instance to compare. + /// true if left and right are both null or have equivalent values; false otherwise. + public static bool operator ==(ChatRole left, ChatRole right) + { + return left.Equals(right); + } + + /// + /// Returns a value indicating whether two instances are not equivalent, as determined by a + /// case-insensitive comparison of their values. + /// + /// the first instance to compare. + /// the second instance to compare. + /// false if left and right are both null or have equivalent values; true otherwise. + public static bool operator !=(ChatRole left, ChatRole right) + { + return !(left == right); + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + => obj is ChatRole otherRole && Equals(otherRole); + + /// + public bool Equals(ChatRole other) + => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() + => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ChatRole Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new(reader.GetString()!); + + /// + public override void Write(Utf8JsonWriter writer, ChatRole value, JsonSerializerOptions options) => + Throw.IfNull(writer).WriteStringValue(value.Value); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs new file mode 100644 index 00000000000..27b8c70e804 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs @@ -0,0 +1,51 @@ +// 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 how tools should be selected by a . +/// +/// +/// The predefined values and are provided. +/// To nominate a specific function, use . +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(AutoChatToolMode), typeDiscriminator: "auto")] +[JsonDerivedType(typeof(RequiredChatToolMode), typeDiscriminator: "required")] +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable +public class ChatToolMode +#pragma warning restore CA1052 +{ + /// Initializes a new instance of the class. + /// Prevents external instantiation. Close the inheritance hierarchy for now until we have good reason to open it. + private protected ChatToolMode() + { + } + + /// + /// Gets a predefined indicating that tool usage is optional. + /// + /// + /// may contain zero or more + /// instances, and the is free to invoke zero or more of them. + /// + public static AutoChatToolMode Auto { get; } = new AutoChatToolMode(); + + /// + /// Gets a predefined indicating that tool usage is required, + /// but that any tool may be selected. At least one tool must be provided in . + /// + public static RequiredChatToolMode RequireAny { get; } = new(requiredFunctionName: null); + + /// + /// Instantiates a indicating that tool usage is required, + /// and that the specified must be selected. The function name + /// must match an entry in . + /// + /// The name of the required function. + /// An instance of for the specified function name. + public static RequiredChatToolMode RequireSpecific(string functionName) => new(functionName); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs new file mode 100644 index 00000000000..a6fb40b3555 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs @@ -0,0 +1,74 @@ +// 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.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +/// +/// This is recommended as a base type when building clients that can be chained in any order around an underlying . +/// The default implementation simply passes each call to the inner client instance. +/// +public class DelegatingChatClient : IChatClient +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped client instance. + protected DelegatingChatClient(IChatClient innerClient) + { + InnerClient = Throw.IfNull(innerClient); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Gets the inner . + protected IChatClient InnerClient { get; } + + /// Provides a mechanism for releasing unmanaged resources. + /// true if being called from ; otherwise, false. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + InnerClient.Dispose(); + } + } + + /// + public virtual ChatClientMetadata Metadata => InnerClient.Metadata; + + /// + public virtual Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return InnerClient.CompleteAsync(chatMessages, options, cancellationToken); + } + + /// + public virtual IAsyncEnumerable CompleteStreamingAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return InnerClient.CompleteStreamingAsync(chatMessages, options, cancellationToken); + } + + /// + public virtual TService? GetService(object? key = null) + where TService : class + { +#pragma warning disable S3060 // "is" should not be used with "this" + // If the key is non-null, we don't know what it means so pass through to the inner service + return key is null && this is TService service ? service : InnerClient.GetService(key); +#pragma warning restore S3060 + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs new file mode 100644 index 00000000000..e9839cab2ae --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// Represents a chat completion client. +public interface IChatClient : IDisposable +{ + /// Sends chat messages to the model and returns the response messages. + /// The chat content to send. + /// The chat options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// The response messages generated by the client. + /// + /// The returned messages will not have been added to . However, any intermediate messages generated implicitly + /// by the client, including any messages for roundtrips to the model as part of the implementation of this request, will be included. + /// + Task CompleteAsync( + IList chatMessages, + ChatOptions? options = null, + CancellationToken cancellationToken = default); + + /// Sends chat messages to the model and streams the response messages. + /// The chat content to send. + /// The chat options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// The response messages generated by the client. + /// + /// The returned messages will not have been added to . However, any intermediate messages generated implicitly + /// by the client, including any messages for roundtrips to the model as part of the implementation of this request, will be included. + /// + IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, + ChatOptions? options = null, + CancellationToken cancellationToken = default); + + /// Gets metadata that describes the . + ChatClientMetadata Metadata { get; } + + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// An optional key that may be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + TService? GetService(object? key = null) + where TService : class; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs new file mode 100644 index 00000000000..a920afaef17 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.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.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Indicates that a chat tool must be called. It may optionally nominate a specific function, +/// or if not, indicates that any of them may be selected. +/// +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class RequiredChatToolMode : ChatToolMode +{ + /// + /// Gets the name of a specific that must be called. + /// + /// + /// If the value is , any available function may be selected (but at least one must be). + /// + public string? RequiredFunctionName { get; } + + /// + /// Initializes a new instance of the class that requires a specific function to be called. + /// + /// The name of the function that must be called. + /// + /// may be . However, it is preferable to use + /// when any function may be selected. + /// + public RequiredChatToolMode(string? requiredFunctionName) + { + if (requiredFunctionName is not null) + { + _ = Throw.IfNullOrWhitespace(requiredFunctionName); + } + + RequiredFunctionName = requiredFunctionName; + } + + // The reason for not overriding Equals/GetHashCode (e.g., so two instances are equal if they + // have the same RequiredFunctionName) is to leave open the option to unseal the type in the + // future. If we did define equality based on RequiredFunctionName but a subclass added further + // fields, this would lead to wrong behavior unless the subclass author remembers to re-override + // Equals/GetHashCode as well, which they likely won't. + + /// Gets a string representing this instance to display in the debugger. + private string DebuggerDisplay => $"Required: {RequiredFunctionName ?? "Any"}"; + + /// + public override bool Equals(object? obj) => + obj is RequiredChatToolMode other && + RequiredFunctionName == other.RequiredFunctionName; + + /// + public override int GetHashCode() => + RequiredFunctionName?.GetHashCode(StringComparison.Ordinal) ?? + typeof(RequiredChatToolMode).GetHashCode(); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs new file mode 100644 index 00000000000..8192e017f7e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +// Conceptually this combines the roles of ChatCompletion and ChatMessage in streaming output. +// For ease of consumption, it also flattens the nested structure you see on streaming chunks in +// the OpenAI/Gemini APIs, so instead of a dictionary of choices, each update represents a single +// choice (and hence has its own role, choice ID, etc.). + +/// +/// Represents a single response chunk from an . +/// +public class StreamingChatCompletionUpdate +{ + /// The completion update content items. + private IList? _contents; + + /// The name of the author of the update. + private string? _authorName; + + /// Gets or sets the name of the author of the completion update. + public string? AuthorName + { + get => _authorName; + set => _authorName = string.IsNullOrWhiteSpace(value) ? null : value; + } + + /// Gets or sets the role of the author of the completion update. + public ChatRole? Role { get; set; } + + /// + /// Gets or sets the text of the first instance in . + /// + /// + /// If there is no instance in , then the getter returns , + /// and the setter will add new instance with the provided value. + /// + [JsonIgnore] + public string? Text + { + get => Contents.OfType().FirstOrDefault()?.Text; + set + { + if (Contents.OfType().FirstOrDefault() is { } textContent) + { + textContent.Text = value; + } + else if (value is not null) + { + Contents.Add(new TextContent(value)); + } + } + } + + /// Gets or sets the chat completion update content items. + [AllowNull] + public IList Contents + { + get => _contents ??= []; + set => _contents = value; + } + + /// Gets or sets the raw representation of the completion update from an underlying implementation. + /// + /// If a is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// Gets or sets additional properties for the update. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Gets or sets the ID of the completion of which this update is a part. + public string? CompletionId { get; set; } + + /// Gets or sets a timestamp for the completion update. + public DateTimeOffset? CreatedAt { get; set; } + + /// Gets or sets the zero-based index of the choice with which this update is associated in the streaming sequence. + public int ChoiceIndex { get; set; } + + /// Gets or sets the finish reason for the operation. + public ChatFinishReason? FinishReason { get; set; } + + /// + public override string ToString() => Text ?? string.Empty; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs new file mode 100644 index 00000000000..456ee4940c2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Provides a base class for all content used with AI services. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(AudioContent), typeDiscriminator: "audio")] +[JsonDerivedType(typeof(DataContent), typeDiscriminator: "data")] +[JsonDerivedType(typeof(FunctionCallContent), typeDiscriminator: "functionCall")] +[JsonDerivedType(typeof(FunctionResultContent), typeDiscriminator: "functionResult")] +[JsonDerivedType(typeof(ImageContent), typeDiscriminator: "image")] +[JsonDerivedType(typeof(TextContent), typeDiscriminator: "text")] +[JsonDerivedType(typeof(UsageContent), typeDiscriminator: "usage")] +public class AIContent +{ + /// + /// Initializes a new instance of the class. + /// + protected AIContent() + { + } + + /// 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 + /// 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 the model ID used to generate the content. + /// + public string? ModelId { get; set; } + + /// Gets or sets additional properties for the content. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AudioContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AudioContent.cs new file mode 100644 index 00000000000..84354a95b1d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AudioContent.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents audio content. +/// +public class AudioContent : DataContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The URI of the content. This may be a data URI. + /// The media type (also known as MIME type) represented by the content. + public AudioContent(Uri uri, string? mediaType = null) + : base(uri, mediaType) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The URI of the content. This may be a data URI. + /// The media type (also known as MIME type) represented by the content. + [JsonConstructor] + public AudioContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null) + : base(uri, mediaType) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The byte contents. + /// The media type (also known as MIME type) represented by the content. + public AudioContent(ReadOnlyMemory data, string? mediaType = null) + : base(data, mediaType) + { + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs new file mode 100644 index 00000000000..5ed17aae1b5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -0,0 +1,196 @@ +// 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.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3996 // URI properties should not be strings +#pragma warning disable CA1056 // URI-like properties should not be strings + +namespace Microsoft.Extensions.AI; + +/// +/// Represents data content, such as an image or audio. +/// +/// +/// +/// The represented content may either be the actual bytes stored in this instance, or it may +/// be a URI that references the location of the content. +/// +/// +/// always returns a valid URI string, even if the instance was constructed from +/// a . In that case, a data URI will be constructed and returned. +/// +/// +public class DataContent : AIContent +{ + // Design note: + // Ideally DataContent would be based in terms of Uri. However, Uri has a length limitation that makes it prohibitive + // for the kinds of data URIs necessary to support here. As such, this type is based in strings. + + /// The string-based representation of the URI, including any data in the instance. + private string? _uri; + + /// The data, lazily-initialized if the data is provided in a data URI. + private ReadOnlyMemory? _data; + + /// Parsed data URI information. + private DataUriParser.DataUri? _dataUri; + + /// + /// Initializes a new instance of the class. + /// + /// The URI of the content. This may be a data URI. + /// The media type (also known as MIME type) represented by the content. + public DataContent(Uri uri, string? mediaType = null) + : this(Throw.IfNull(uri).ToString(), mediaType) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The URI of the content. This may be a data URI. + /// The media type (also known as MIME type) represented by the content. + [JsonConstructor] + public DataContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null) + { + _uri = Throw.IfNullOrWhitespace(uri); + + ValidateMediaType(ref mediaType); + MediaType = mediaType; + + if (uri.StartsWith(DataUriParser.Scheme, StringComparison.OrdinalIgnoreCase)) + { + _dataUri = DataUriParser.Parse(uri.AsMemory()); + + // If the data URI contains a media type that's different from a non-null media type + // explicitly provided, prefer the one explicitly provided as an override. + if (MediaType is not null) + { + if (MediaType != _dataUri.MediaType) + { + // Extract the bytes from the data URI and null out the uri. + // Then we'll lazily recreate it later if needed based on the updated media type. + _data = _dataUri.ToByteArray(); + _dataUri = null; + _uri = null; + } + } + else + { + MediaType = _dataUri.MediaType; + } + } + else if (!System.Uri.TryCreate(uri, UriKind.Absolute, out _)) + { + throw new UriFormatException("The URI is not well-formed."); + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The byte contents. + /// The media type (also known as MIME type) represented by the content. + public DataContent(ReadOnlyMemory data, string? mediaType = null) + { + ValidateMediaType(ref mediaType); + MediaType = mediaType; + + _data = data; + } + + /// Sets to null if it's empty or composed entirely of whitespace. + private static void ValidateMediaType(ref string? mediaType) + { + if (!DataUriParser.IsValidMediaType(mediaType.AsSpan(), ref mediaType)) + { + Throw.ArgumentException(nameof(mediaType), "Invalid media type."); + } + } + + /// Gets the URI for this . + /// + /// The returned URI is always a valid URI string, even if the instance was constructed from a + /// or from a . In the case of a , this will return a data URI containing + /// that data. + /// + [StringSyntax(StringSyntaxAttribute.Uri)] + public string Uri + { + get + { + if (_uri is null) + { + if (_dataUri is null) + { + Debug.Assert(Data is not null, "Expected Data to be initialized."); + _uri = string.Concat("data:", MediaType, ";base64,", Convert.ToBase64String(Data.GetValueOrDefault() +#if NET + .Span)); +#else + .Span.ToArray())); +#endif + } + else + { + _uri = _dataUri.IsBase64 ? +#if NET + $"data:{MediaType};base64,{_dataUri.Data.Span}" : + $"data:{MediaType};,{_dataUri.Data.Span}"; +#else + $"data:{MediaType};base64,{_dataUri.Data}" : + $"data:{MediaType};,{_dataUri.Data}"; +#endif + } + } + + return _uri; + } + } + + /// Gets the media type (also known as MIME type) of the content. + /// + /// If the media type was explicitly specified, this property will return that value. + /// If the media type was not explicitly specified, but a data URI was supplied and that data URI contained a non-default + /// media type, that media type will be returned. + /// Otherwise, this will return null. + /// + [JsonPropertyOrder(1)] + public string? MediaType { get; private set; } + + /// + /// Gets a value indicating whether the content contains data rather than only being a reference to data. + /// + /// + /// If the instance is constructed from a or from a data URI, this property will return , + /// as the instance actually contains all of the data it represents. If, however, the instance was constructed from another form of URI, one + /// that simply references where the data can be found but doesn't actually contain the data, this property will return . + /// + [JsonIgnore] + public bool ContainsData => _dataUri is not null || _data is not null; + + /// Gets the data represented by this instance. + /// + /// If is , this property will return the represented data. + /// If is , this property will return . + /// + [MemberNotNullWhen(true, nameof(ContainsData))] + [JsonIgnore] + public ReadOnlyMemory? Data + { + get + { + if (_dataUri is not null) + { + _data ??= _dataUri.ToByteArray(); + } + + return _data; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs new file mode 100644 index 00000000000..5cb33d1a55c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs @@ -0,0 +1,182 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +#if NET8_0_OR_GREATER +using System.Buffers.Text; +#endif +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace Microsoft.Extensions.AI; + +/// +/// Minimal data URI parser based on RFC 2397: https://datatracker.ietf.org/doc/html/rfc2397. +/// +internal static class DataUriParser +{ + public static string Scheme => "data:"; + + public static DataUri Parse(ReadOnlyMemory dataUri) + { + // Validate, then trim off the "data:" scheme. + if (!dataUri.Span.StartsWith(Scheme.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + throw new UriFormatException("Invalid data URI format: the data URI must start with 'data:'."); + } + + dataUri = dataUri.Slice(Scheme.Length); + + // Find the comma separating the metadata from the data. + int commaPos = dataUri.Span.IndexOf(','); + if (commaPos < 0) + { + throw new UriFormatException("Invalid data URI format: the data URI must contain a comma separating the metadata and the data."); + } + + ReadOnlyMemory metadata = dataUri.Slice(0, commaPos); + + ReadOnlyMemory data = dataUri.Slice(commaPos + 1); + bool isBase64 = false; + + // Determine whether the data is Base64-encoded or percent-encoded (Uri-encoded). + // If it's base64-encoded, validate it. If it's Uri-encoded, there's nothing to validate, + // as WebUtility.UrlDecode will successfully decode any input with no sequence considered invalid. + if (metadata.Span.EndsWith(";base64".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + metadata = metadata.Slice(0, metadata.Length - ";base64".Length); + isBase64 = true; + if (!IsValidBase64Data(data.Span)) + { + throw new UriFormatException("Invalid data URI format: the data URI is base64-encoded, but the data is not a valid base64 string."); + } + } + + // Validate the media type, if present. + string? mediaType = null; + if (!IsValidMediaType(metadata.Span.Trim(), ref mediaType)) + { + throw new UriFormatException("Invalid data URI format: the media type is not a valid."); + } + + return new DataUri(data, isBase64, mediaType); + } + + /// Validates that a media type is valid, and if successful, ensures we have it as a string. + public static bool IsValidMediaType(ReadOnlySpan mediaTypeSpan, ref string? mediaType) + { + Debug.Assert( + mediaType is null || mediaTypeSpan.Equals(mediaType.AsSpan(), StringComparison.Ordinal), + "mediaType string should either be null or the same as the span"); + + // If the media type is empty or all whitespace, normalize it to null. + if (mediaTypeSpan.IsWhiteSpace()) + { + mediaType = null; + return true; + } + + // For common media types, we can avoid both allocating a string for the span and avoid parsing overheads. + string? knownType = mediaTypeSpan switch + { + "application/json" => "application/json", + "application/octet-stream" => "application/octet-stream", + "application/pdf" => "application/pdf", + "application/xml" => "application/xml", + "audio/mpeg" => "audio/mpeg", + "audio/ogg" => "audio/ogg", + "audio/wav" => "audio/wav", + "image/apng" => "image/apng", + "image/avif" => "image/avif", + "image/bmp" => "image/bmp", + "image/gif" => "image/gif", + "image/jpeg" => "image/jpeg", + "image/png" => "image/png", + "image/svg+xml" => "image/svg+xml", + "image/tiff" => "image/tiff", + "image/webp" => "image/webp", + "text/css" => "text/css", + "text/csv" => "text/csv", + "text/html" => "text/html", + "text/javascript" => "text/javascript", + "text/plain" => "text/plain", + "text/plain;charset=UTF-8" => "text/plain;charset=UTF-8", + "text/xml" => "text/xml", + _ => null, + }; + if (knownType is not null) + { + mediaType ??= knownType; + return true; + } + + // Otherwise, do the full validation using the same logic as HttpClient. + mediaType ??= mediaTypeSpan.ToString(); + return MediaTypeHeaderValue.TryParse(mediaType, out _); + } + + /// Test whether the value is a base64 string without whitespace. + private static bool IsValidBase64Data(ReadOnlySpan value) + { + if (value.IsEmpty) + { + return true; + } + +#if NET8_0_OR_GREATER + return Base64.IsValid(value) && !value.ContainsAny(" \t\r\n"); +#else +#pragma warning disable S109 // Magic numbers should not be used + if (value!.Length % 4 != 0) +#pragma warning restore S109 + { + return false; + } + + var index = value.Length - 1; + + // Step back over one or two padding chars + if (value[index] == '=') + { + index--; + } + + if (value[index] == '=') + { + index--; + } + + // Now traverse over characters + for (var i = 0; i <= index; i++) + { +#pragma warning disable S1067 // Expressions should not be too complex + bool validChar = value[i] is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '+' or '/'; +#pragma warning restore S1067 + if (!validChar) + { + return false; + } + } + + return true; +#endif + } + + /// Provides the parts of a parsed data URI. + public sealed class DataUri(ReadOnlyMemory data, bool isBase64, string? mediaType) + { +#pragma warning disable S3604 // False positive: Member initializer values should not be redundant + public string? MediaType { get; } = mediaType; + + public ReadOnlyMemory Data { get; } = data; + + public bool IsBase64 { get; } = isBase64; +#pragma warning restore S3604 + + public byte[] ToByteArray() => IsBase64 ? + Convert.FromBase64String(Data.ToString()) : + Encoding.UTF8.GetBytes(WebUtility.UrlDecode(Data.ToString())); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs new file mode 100644 index 00000000000..7eefdd90a09 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a function call request. +/// +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class FunctionCallContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The function call ID. + /// The function name. + /// The function original arguments. + [JsonConstructor] + public FunctionCallContent(string callId, string name, IDictionary? arguments = null) + { + Name = Throw.IfNull(name); + CallId = callId; + Arguments = arguments; + } + + /// + /// Gets or sets the function call ID. + /// + public string CallId { get; set; } + + /// + /// Gets or sets the name of the function requested. + /// + public string Name { get; set; } + + /// + /// Gets or sets the arguments requested to be provided to the function. + /// + public IDictionary? Arguments { get; set; } + + /// + /// Gets or sets any exception that occurred while mapping the original function call data to this class. + /// + /// + /// When an instance of is serialized using , any exception + /// stored in this property will be serialized as a string. When deserialized, the string will be converted back to an instance + /// of the base type. As such, consumers shouldn't rely on the exact type of the exception stored in this property. + /// + [JsonConverter(typeof(FunctionCallExceptionConverter))] + public Exception? Exception { get; set; } + + /// Gets a string representing this instance to display in the debugger. + private string DebuggerDisplay + { + get + { + string display = CallId is not null ? + $"CallId = {CallId}, " : + string.Empty; + + display += Arguments is not null ? + $"Call = {Name}({string.Join(", ", Arguments)})" : + $"Call = {Name}()"; + + return display; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallExceptionConverter.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallExceptionConverter.cs new file mode 100644 index 00000000000..0c36f11ca40 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallExceptionConverter.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ComponentModel; +#if NET +using System.Runtime.ExceptionServices; +#endif +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Serializes an exception as a string and deserializes it back as a base containing that contents as a message. +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class FunctionCallExceptionConverter : JsonConverter +{ + private const string ClassNamePropertyName = "className"; + private const string MessagePropertyName = "message"; + private const string InnerExceptionPropertyName = "innerException"; + private const string StackTracePropertyName = "stackTraceString"; + + /// + public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options) + { + _ = Throw.IfNull(writer); + _ = Throw.IfNull(value); + + // Schema and property order taken from Exception.GetObjectData() implementation. + + writer.WriteStartObject(); + writer.WriteString(ClassNamePropertyName, value.GetType().ToString()); + writer.WriteString(MessagePropertyName, value.Message); + writer.WritePropertyName(InnerExceptionPropertyName); + if (value.InnerException is Exception innerEx) + { + Write(writer, innerEx, options); + } + else + { + writer.WriteNullValue(); + } + + writer.WriteString(StackTracePropertyName, value.StackTrace); + writer.WriteEndObject(); + } + + /// + public override Exception? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException(); + } + + using var doc = JsonDocument.ParseValue(ref reader); + return ParseExceptionCore(doc.RootElement); + + static Exception ParseExceptionCore(JsonElement element) + { + string? message = null; + string? stackTrace = null; + Exception? innerEx = null; + + foreach (JsonProperty property in element.EnumerateObject()) + { + switch (property.Name) + { + case MessagePropertyName: + message = property.Value.GetString(); + break; + + case StackTracePropertyName: + stackTrace = property.Value.GetString(); + break; + + case InnerExceptionPropertyName when property.Value.ValueKind is not JsonValueKind.Null: + innerEx = ParseExceptionCore(property.Value); + break; + } + } + +#pragma warning disable CA2201 // Do not raise reserved exception types + Exception result = new(message, innerEx); +#pragma warning restore CA2201 +#if NET + if (stackTrace != null) + { + ExceptionDispatchInfo.SetRemoteStackTrace(result, stackTrace); + } +#endif + return result; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs new file mode 100644 index 00000000000..42eb486f4c1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs @@ -0,0 +1,378 @@ +// 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.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +using FunctionParameterKey = (System.Type? Type, string ParameterName, string? Description, bool HasDefaultValue, object? DefaultValue); + +namespace Microsoft.Extensions.AI; + +/// Provides a collection of static utility methods for marshalling JSON data in function calling. +internal static partial class FunctionCallHelpers +{ + /// Soft limit for how many items should be stored in the dictionaries in . + private const int CacheSoftLimit = 4096; + + /// Caches of generated schemas for each that's employed. + private static readonly ConditionalWeakTable> _schemaCaches = new(); + + /// Gets a JSON schema accepting all values. + private static JsonElement TrueJsonSchema { get; } = ParseJsonElement("true"u8); + + /// Gets a JSON schema only accepting null values. + private static JsonElement NullJsonSchema { get; } = ParseJsonElement("""{"type":"null"}"""u8); + + /// Parses a JSON object into a dictionary of objects encoded as . + /// A JSON object containing the parameters. + /// If the parsing fails, the resulting exception. + /// The parsed dictionary of objects encoded as . + public static Dictionary? ParseFunctionCallArguments(string json, out Exception? parsingException) + { + _ = Throw.IfNull(json); + + parsingException = null; + try + { + return JsonSerializer.Deserialize(json, FunctionCallHelperContext.Default.DictionaryStringObject); + } + catch (JsonException ex) + { + parsingException = new InvalidOperationException($"Function call arguments contained invalid JSON: {json}", ex); + return null; + } + } + + /// Parses a JSON object into a dictionary of objects encoded as . + /// A UTF-8 encoded JSON object containing the parameters. + /// If the parsing fails, the resulting exception. + /// The parsed dictionary of objects encoded as . + public static Dictionary? ParseFunctionCallArguments(ReadOnlySpan utf8Json, out Exception? parsingException) + { + parsingException = null; + try + { + return JsonSerializer.Deserialize(utf8Json, FunctionCallHelperContext.Default.DictionaryStringObject); + } + catch (JsonException ex) + { + parsingException = new InvalidOperationException($"Function call arguments contained invalid JSON: {Encoding.UTF8.GetString(utf8Json.ToArray())}", ex); + return null; + } + } + + /// + /// Serializes a dictionary of function parameters into a JSON string. + /// + /// The dictionary of parameters. + /// A governing serialization. + /// A JSON encoding of the parameters. + public static string FormatFunctionParametersAsJson(IDictionary? parameters, JsonSerializerOptions? options = null) + { + // Fall back to the built-in context since in most cases the return value is JsonElement or JsonNode. + options ??= FunctionCallHelperContext.Default.Options; + options.MakeReadOnly(); + return JsonSerializer.Serialize(parameters, options.GetTypeInfo(typeof(IDictionary))); + } + + /// + /// Serializes a dictionary of function parameters into a . + /// + /// The dictionary of parameters. + /// A governing serialization. + /// A JSON encoding of the parameters. + public static JsonElement FormatFunctionParametersAsJsonElement(IDictionary? parameters, JsonSerializerOptions? options = null) + { + // Fall back to the built-in context since in most cases the return value is JsonElement or JsonNode. + options ??= FunctionCallHelperContext.Default.Options; + options.MakeReadOnly(); + return JsonSerializer.SerializeToElement(parameters, options.GetTypeInfo(typeof(IDictionary))); + } + + /// + /// Serializes a .NET function return parameter to a JSON string. + /// + /// The result value to be serialized. + /// A governing serialization. + /// A JSON encoding of the parameter. + public static string FormatFunctionResultAsJson(object? result, JsonSerializerOptions? options = null) + { + // Fall back to the built-in context since in most cases the return value is JsonElement or JsonNode. + options ??= FunctionCallHelperContext.Default.Options; + options.MakeReadOnly(); + return JsonSerializer.Serialize(result, options.GetTypeInfo(typeof(object))); + } + + /// + /// Serializes a .NET function return parameter to a JSON element. + /// + /// The result value to be serialized. + /// A governing serialization. + /// A JSON encoding of the parameter. + public static JsonElement FormatFunctionResultAsJsonElement(object? result, JsonSerializerOptions? options = null) + { + // Fall back to the built-in context since in most cases the return value is JsonElement or JsonNode. + options ??= FunctionCallHelperContext.Default.Options; + options.MakeReadOnly(); + return JsonSerializer.SerializeToElement(result, options.GetTypeInfo(typeof(object))); + } + + /// + /// Determines a JSON schema for the provided parameter metadata. + /// + /// The parameter metadata from which to infer the schema. + /// The containing function metadata. + /// The global governing serialization. + /// A JSON schema document encoded as a . + public static JsonElement InferParameterJsonSchema( + AIFunctionParameterMetadata parameterMetadata, + AIFunctionMetadata functionMetadata, + JsonSerializerOptions? options) + { + options ??= functionMetadata.JsonSerializerOptions; + + if (ReferenceEquals(options, functionMetadata.JsonSerializerOptions) && + parameterMetadata.Schema is JsonElement schema) + { + // If the resolved options matches that of the function metadata, + // we can just return the precomputed JSON schema value. + return schema; + } + + if (options is null) + { + return TrueJsonSchema; + } + + return InferParameterJsonSchema( + parameterMetadata.ParameterType, + parameterMetadata.Name, + parameterMetadata.Description, + parameterMetadata.HasDefaultValue, + parameterMetadata.DefaultValue, + options); + } + + /// + /// Determines a JSON schema for the provided parameter metadata. + /// + /// The type of the parameter. + /// The name of the parameter. + /// The description of the parameter. + /// Whether the parameter is optional. + /// The default value of the optional parameter, if applicable. + /// The options used to extract the schema from the specified type. + /// A JSON schema document encoded as a . + public static JsonElement InferParameterJsonSchema( + Type? type, + string name, + string? description, + bool hasDefaultValue, + object? defaultValue, + JsonSerializerOptions options) + { + _ = Throw.IfNull(name); + _ = Throw.IfNull(options); + + options.MakeReadOnly(); + + try + { + ConcurrentDictionary cache = _schemaCaches.GetOrCreateValue(options); + FunctionParameterKey key = new(type, name, description, hasDefaultValue, defaultValue); + + if (cache.Count > CacheSoftLimit) + { + return GetJsonSchemaCore(options, key); + } + + return cache.GetOrAdd( + key: key, +#if NET + valueFactory: static (key, options) => GetJsonSchemaCore(options, key), + factoryArgument: options); +#else + valueFactory: key => GetJsonSchemaCore(options, key)); +#endif + } + catch (ArgumentException) + { + // Invalid type; ignore, and leave schema as null. + // This should be exceedingly rare, as we checked for all known category of + // problematic types above. If it becomes more common that schema creation + // could fail expensively, we'll want to track whether inference was already + // attempted and avoid doing so on subsequent accesses if it was. + return TrueJsonSchema; + } + } + + /// Infers a JSON schema from the return parameter. + /// The type of the return parameter. + /// The options used to extract the schema from the specified type. + /// A representing the schema. + public static JsonElement InferReturnParameterJsonSchema(Type? type, JsonSerializerOptions options) + { + _ = Throw.IfNull(options); + + options.MakeReadOnly(); + + // If there's no type, just return a schema that allows anything. + if (type is null) + { + return TrueJsonSchema; + } + + if (type == typeof(void)) + { + return NullJsonSchema; + } + + JsonNode node = options.GetJsonSchemaAsNode(type); + return JsonSerializer.SerializeToElement(node, FunctionCallHelperContext.Default.JsonNode); + } + + private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, FunctionParameterKey key) + { + _ = Throw.IfNull(options); + + if (options.ReferenceHandler == ReferenceHandler.Preserve) + { + throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); + } + + if (key.Type is null) + { + // For parameters without a type generate a rudimentary schema with available metadata. + + JsonObject schemaObj = []; + if (key.Description is not null) + { + schemaObj["description"] = key.Description; + } + + if (key.HasDefaultValue) + { + JsonNode? defaultValueNode = key.DefaultValue is { } defaultValue + ? JsonSerializer.Serialize(defaultValue, options.GetTypeInfo(defaultValue.GetType())) + : null; + + schemaObj["default"] = defaultValueNode; + } + + return JsonSerializer.SerializeToElement(schemaObj, FunctionCallHelperContext.Default.JsonNode); + } + + options.MakeReadOnly(); + + JsonSchemaExporterOptions exporterOptions = new() + { + TreatNullObliviousAsNonNullable = true, + TransformSchemaNode = TransformSchemaNode, + }; + + JsonNode node = options.GetJsonSchemaAsNode(key.Type, exporterOptions); + return JsonSerializer.SerializeToElement(node, FunctionCallHelperContext.Default.JsonNode); + + JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) + { + const string DescriptionPropertyName = "description"; + const string NotPropertyName = "not"; + const string PropertiesPropertyName = "properties"; + const string DefaultPropertyName = "default"; + const string RefPropertyName = "$ref"; + + // Find the first DescriptionAttribute, starting first from the property, then the parameter, and finally the type itself. + Type descAttrType = typeof(DescriptionAttribute); + var descriptionAttribute = + GetAttrs(descAttrType, ctx.PropertyInfo?.AttributeProvider)?.FirstOrDefault() ?? + GetAttrs(descAttrType, ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider)?.FirstOrDefault() ?? + GetAttrs(descAttrType, ctx.TypeInfo.Type)?.FirstOrDefault(); + + if (descriptionAttribute is DescriptionAttribute attr) + { + ConvertSchemaToObject(ref schema).Insert(0, DescriptionPropertyName, (JsonNode)attr.Description); + } + + // If the type is recursive, the resulting schema will contain a $ref to the type itself. + // As JSON pointer doesn't support relative paths, we need to fix up such paths to accommodate + // the fact that they're being nested inside of a higher-level schema. + if (schema is JsonObject refObj && refObj.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}/{key.ParameterName}" + : $"#/{PropertiesPropertyName}/{key.ParameterName}/{refUri.AsMemory("#/".Length)}"; + + refObj[RefPropertyName] = (JsonNode)refUri; + } + + if (ctx.Path.IsEmpty) + { + // We are at the root-level schema node, append parameter-specific metadata + + if (!string.IsNullOrWhiteSpace(key.Description)) + { + ConvertSchemaToObject(ref schema).Insert(0, DescriptionPropertyName, (JsonNode)key.Description!); + } + + if (key.HasDefaultValue) + { + JsonNode? defaultValue = JsonSerializer.Serialize(key.DefaultValue, options.GetTypeInfo(typeof(object))); + ConvertSchemaToObject(ref schema)[DefaultPropertyName] = defaultValue; + } + } + + return schema; + + static object[]? GetAttrs(Type attrType, ICustomAttributeProvider? provider) => + provider?.GetCustomAttributes(attrType, inherit: false); + + static JsonObject ConvertSchemaToObject(ref JsonNode schema) + { + JsonObject obj; + JsonValueKind kind = schema.GetValueKind(); + switch (kind) + { + case JsonValueKind.Object: + return (JsonObject)schema; + + case JsonValueKind.False: + schema = obj = new() { [NotPropertyName] = true }; + return obj; + + default: + Debug.Assert(kind is JsonValueKind.True, $"Invalid schema type: {kind}"); + schema = obj = []; + return obj; + } + } + } + } + + private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) + { + Utf8JsonReader reader = new(utf8Json); + return JsonElement.ParseValue(ref reader); + } + + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(JsonNode))] + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(JsonDocument))] + private sealed partial class FunctionCallHelperContext : JsonSerializerContext; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs new file mode 100644 index 00000000000..0a416d64f5f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs @@ -0,0 +1,91 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the result of a function call. +/// +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class FunctionResultContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The function call ID for which this is the result. + /// The function name that produced the result. + /// The function call result. + /// Any exception that occurred when invoking the function. + [JsonConstructor] + public FunctionResultContent(string callId, string name, object? result = null, Exception? exception = null) + { + CallId = Throw.IfNull(callId); + Name = Throw.IfNull(name); + Result = result; + Exception = exception; + } + + /// + /// Initializes a new instance of the class. + /// + /// The function call for which this is the result. + /// The function call result. + /// Any exception that occurred when invoking the function. + public FunctionResultContent(FunctionCallContent functionCall, object? result = null, Exception? exception = null) + : this(Throw.IfNull(functionCall).CallId, functionCall.Name, result, exception) + { + } + + /// + /// Gets or sets the ID of the function call for which this is the result. + /// + /// + /// If this is the result for a , this should contain the same + /// value. + /// + public string CallId { get; set; } + + /// + /// Gets or sets the name of the function that was called. + /// + public string Name { get; set; } + + /// + /// Gets or sets the result of the function call, or a generic error message if the function call failed. + /// + public object? Result { get; set; } + + /// + /// Gets or sets an exception that occurred if the function call failed. + /// + /// + /// When an instance of is serialized using , any exception + /// stored in this property will be serialized as a string. When deserialized, the string will be converted back to an instance + /// of the base type. As such, consumers shouldn't rely on the exact type of the exception stored in this property. + /// + [JsonConverter(typeof(FunctionCallExceptionConverter))] + public Exception? Exception { get; set; } + + /// Gets a string representing this instance to display in the debugger. + private string DebuggerDisplay + { + get + { + string display = CallId is not null ? + $"CallId = {CallId}, " : + string.Empty; + + display += Exception is not null ? + $"Error = {Exception.Message}" : + $"Result = {Result?.ToString() ?? string.Empty}"; + + return display; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageContent.cs new file mode 100644 index 00000000000..d376586c993 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageContent.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents image content. +/// +public class ImageContent : DataContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The URI of the content. This may be a data URI. + /// The media type (also known as MIME type) represented by the content. + public ImageContent(Uri uri, string? mediaType = null) + : base(uri, mediaType) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The URI of the content. This may be a data URI. + /// The media type (also known as MIME type) represented by the content. + [JsonConstructor] + public ImageContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null) + : base(uri, mediaType) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The byte contents. + /// The media type (also known as MIME type) represented by the content. + public ImageContent(ReadOnlyMemory data, string? mediaType = null) + : base(data, mediaType) + { + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs new file mode 100644 index 00000000000..d81e969e1c4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +/// +/// Represents text content in a chat. +/// +public sealed class TextContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The text content. + public TextContent(string? text) + { + Text = text; + } + + /// + /// Gets or sets the text content. + /// + public string? Text { get; set; } + + /// + public override string ToString() => Text ?? string.Empty; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UsageContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UsageContent.cs new file mode 100644 index 00000000000..22d86bd97cb --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UsageContent.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents usage information associated with a chat response. +/// +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public class UsageContent : AIContent +{ + /// Usage information. + private UsageDetails _details; + + /// Initializes a new instance of the class with an empty . + public UsageContent() + { + _details = new(); + } + + /// Initializes a new instance of the class with the specified instance. + /// The usage details to store in this content. + [JsonConstructor] + public UsageContent(UsageDetails details) + { + _details = Throw.IfNull(details); + } + + /// Gets or sets the usage information. + public UsageDetails Details + { + get => _details; + set => _details = Throw.IfNull(value); + } + + /// Gets a string representing this instance to display in the debugger. + private string DebuggerDisplay => _details.DebuggerDisplay; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs new file mode 100644 index 00000000000..6b06d32d6d7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs @@ -0,0 +1,70 @@ +// 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.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +/// Specifies the type of the input passed to the generator. +/// Specifies the type of the embedding instance produced by the generator. +/// +/// This is recommended as a base type when building generators that can be chained in any order around an underlying . +/// The default implementation simply passes each call to the inner generator instance. +/// +public class DelegatingEmbeddingGenerator : IEmbeddingGenerator + where TEmbedding : Embedding +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped generator instance. + protected DelegatingEmbeddingGenerator(IEmbeddingGenerator innerGenerator) + { + InnerGenerator = Throw.IfNull(innerGenerator); + } + + /// Gets the inner . + protected IEmbeddingGenerator InnerGenerator { get; } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Provides a mechanism for releasing unmanaged resources. + /// true if being called from ; otherwise, false. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + InnerGenerator.Dispose(); + } + } + + /// + public virtual EmbeddingGeneratorMetadata Metadata => + InnerGenerator.Metadata; + + /// + public virtual Task> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) => + InnerGenerator.GenerateAsync(values, options, cancellationToken); + + /// + public virtual TService? GetService(object? key = null) + where TService : class + { +#pragma warning disable S3060 // "is" should not be used with "this" + // If the key is non-null, we don't know what it means so pass through to the inner service + return key is null && this is TService service ? service : InnerGenerator.GetService(key); +#pragma warning restore S3060 + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/Embedding.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/Embedding.cs new file mode 100644 index 00000000000..e70469eaed3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/Embedding.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; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Represents an embedding generated by a . +/// This base class provides metadata about the embedding. Derived types provide the concrete data contained in the embedding. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +#if NET +[JsonDerivedType(typeof(Embedding), typeDiscriminator: "halves")] +#endif +[JsonDerivedType(typeof(Embedding), typeDiscriminator: "floats")] +[JsonDerivedType(typeof(Embedding), typeDiscriminator: "doubles")] +public class Embedding +{ + /// Initializes a new instance of the class. + protected Embedding() + { + } + + /// Gets or sets a timestamp at which the embedding was created. + public DateTimeOffset? CreatedAt { get; set; } + + /// Gets or sets the model ID using in the creation of the embedding. + public string? ModelId { get; set; } + + /// Gets or sets any additional properties associated with the embedding. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs new file mode 100644 index 00000000000..bd010d5f447 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +/// Represents the options for an embedding generation request. +public class EmbeddingGenerationOptions +{ + /// Gets or sets the model ID for the embedding generation request. + public string? ModelId { get; set; } + + /// Gets or sets additional properties for the embedding generation request. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Produces a clone of the current instance. + /// A clone of the current instance. + /// + /// The clone will have the same values for all properties as the original instance. Any collections, like + /// are shallow-cloned, meaning a new collection instance is created, but any references contained by the collections are shared with the original. + /// + public virtual EmbeddingGenerationOptions Clone() + { + EmbeddingGenerationOptions options = new() + { + ModelId = ModelId, + }; + + if (AdditionalProperties is not null) + { + options.AdditionalProperties = new(AdditionalProperties); + } + + return options; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs new file mode 100644 index 00000000000..fa2a1df4fbe --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.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.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides a collection of static methods for extending instances. +public static class EmbeddingGeneratorExtensions +{ + /// Generates an embedding from the specified . + /// The type from which embeddings will be generated. + /// The numeric type of the embedding data. + /// The embedding generator. + /// A value from which an embedding will be generated. + /// The embedding generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// The generated embedding for the specified . + public static Task> GenerateAsync( + this IEmbeddingGenerator generator, + TValue value, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + where TEmbedding : Embedding + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(value); + + return generator.GenerateAsync([value], options, cancellationToken); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs new file mode 100644 index 00000000000..39bdd61d3ae --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs @@ -0,0 +1,36 @@ +// 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; + +/// Provides metadata about an . +public class EmbeddingGeneratorMetadata +{ + /// Initializes a new instance of the class. + /// The name of the embedding generation provider, if applicable. + /// The URL for accessing the embedding generation provider, if applicable. + /// The id of the embedding generation model used, if applicable. + /// The number of dimensions in vectors produced by this generator, if applicable. + public EmbeddingGeneratorMetadata(string? providerName = null, Uri? providerUri = null, string? modelId = null, int? dimensions = null) + { + ModelId = modelId; + ProviderName = providerName; + ProviderUri = providerUri; + Dimensions = dimensions; + } + + /// Gets the name of the embedding generation provider. + public string? ProviderName { get; } + + /// Gets the URL for accessing the embedding generation provider. + public Uri? ProviderUri { get; } + + /// Gets the id of the model used by this embedding generation provider. + /// This may be null if either the name is unknown or there are multiple possible models associated with this instance. + public string? ModelId { get; } + + /// Gets the number of dimensions in the embeddings produced by this instance. + public int? Dimensions { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/Embedding{T}.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/Embedding{T}.cs new file mode 100644 index 00000000000..c80e20dfda4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/Embedding{T}.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; + +namespace Microsoft.Extensions.AI; + +/// Represents an embedding composed of a vector of values. +/// The type of the values in the embedding vector. +/// Typical values of are , , or Half. +public sealed class Embedding : Embedding +{ + /// Initializes a new instance of the class with the embedding vector. + /// The embedding vector this embedding represents. + public Embedding(ReadOnlyMemory vector) + { + Vector = vector; + } + + /// Gets or sets the embedding vector this embedding represents. + public ReadOnlyMemory Vector { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/GeneratedEmbeddings.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/GeneratedEmbeddings.cs new file mode 100644 index 00000000000..e983dd3b64b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/GeneratedEmbeddings.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.Collections; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents the result of an operation to generate embeddings. +/// Specifies the type of the generated embeddings. +public sealed class GeneratedEmbeddings : IList, IReadOnlyList + where TEmbedding : Embedding +{ + /// The underlying list of embeddings. + private List _embeddings; + + /// Initializes a new instance of the class. + public GeneratedEmbeddings() + { + _embeddings = []; + } + + /// Initializes a new instance of the class with the specified capacity. + /// The number of embeddings that the new list can initially store. + public GeneratedEmbeddings(int capacity) + { + _embeddings = new List(Throw.IfLessThan(capacity, 0)); + } + + /// + /// Initializes a new instance of the class that contains all of the embeddings from the specified collection. + /// + /// The collection whose embeddings are copied to the new list. + public GeneratedEmbeddings(IEnumerable embeddings) + { + _embeddings = new List(Throw.IfNull(embeddings)); + } + + /// Gets or sets usage details for the embeddings' generation. + public UsageDetails? Usage { get; set; } + + /// Gets or sets any additional properties associated with the embeddings. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// + public TEmbedding this[int index] + { + get => _embeddings[index]; + set => _embeddings[index] = value; + } + + /// + public int Count => _embeddings.Count; + + /// + bool ICollection.IsReadOnly => false; + + /// + public void Add(TEmbedding item) => _embeddings.Add(item); + + /// Adds the embeddings from the specified collection to the end of this list. + /// The collection whose elements should be added to this list. + public void AddRange(IEnumerable items) => _embeddings.AddRange(items); + + /// + public void Clear() => _embeddings.Clear(); + + /// + public bool Contains(TEmbedding item) => _embeddings.Contains(item); + + /// + public void CopyTo(TEmbedding[] array, int arrayIndex) => _embeddings.CopyTo(array, arrayIndex); + + /// + public IEnumerator GetEnumerator() => _embeddings.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public int IndexOf(TEmbedding item) => _embeddings.IndexOf(item); + + /// + public void Insert(int index, TEmbedding item) => _embeddings.Insert(index, item); + + /// + public bool Remove(TEmbedding item) => _embeddings.Remove(item); + + /// + public void RemoveAt(int index) => _embeddings.RemoveAt(index); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs new file mode 100644 index 00000000000..6c791ee2bf4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// Represents a generator of embeddings. +/// The type from which embeddings will be generated. +/// The type of embeddings to generate. +public interface IEmbeddingGenerator : IDisposable + where TEmbedding : Embedding +{ + /// Generates embeddings for each of the supplied . + /// The collection of values for which to generate embeddings. + /// The embedding generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// The generated embeddings. + Task> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default); + + /// Gets metadata that describes the . + EmbeddingGeneratorMetadata Metadata { get; } + + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// An optional key that may be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + TService? GetService(object? key = null) + where TService : class; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs new file mode 100644 index 00000000000..a4b5ecb5378 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs @@ -0,0 +1,49 @@ +// 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; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Collections; + +namespace Microsoft.Extensions.AI; + +/// Represents a function that can be described to an AI service and invoked. +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public abstract class AIFunction : AITool +{ + /// Gets metadata describing the function. + public abstract AIFunctionMetadata Metadata { get; } + + /// Invokes the and returns its result. + /// The arguments to pass to the function's invocation. + /// The to monitor for cancellation requests. The default is . + /// The result of the function's execution. + public Task InvokeAsync( + IEnumerable>? arguments = null, + CancellationToken cancellationToken = default) + { + arguments ??= EmptyReadOnlyDictionary.Instance; + + return InvokeCoreAsync(arguments, cancellationToken); + } + + /// + public override string ToString() => Metadata.Name; + + /// Invokes the and returns its result. + /// The arguments to pass to the function's invocation. + /// The to monitor for cancellation requests. + /// The result of the function's execution. + protected abstract Task InvokeCoreAsync( + IEnumerable> arguments, + CancellationToken cancellationToken); + + /// Gets the string to display in the debugger for this instance. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay => + string.IsNullOrWhiteSpace(Metadata.Description) ? + Metadata.Name : + $"{Metadata.Name} ({Metadata.Description})"; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs new file mode 100644 index 00000000000..03dac25d15f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs @@ -0,0 +1,112 @@ +// 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.Text.Json; +using Microsoft.Shared.Collections; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides read-only metadata for an . +/// +public sealed class AIFunctionMetadata +{ + /// The name of the function. + private string _name = string.Empty; + + /// The description of the function. + private string _description = string.Empty; + + /// The function's parameters. + private IReadOnlyList _parameters = []; + + /// The function's return parameter. + private AIFunctionReturnParameterMetadata _returnParameter = AIFunctionReturnParameterMetadata.Empty; + + /// Optional additional properties in addition to the named properties already available on this class. + private IReadOnlyDictionary _additionalProperties = EmptyReadOnlyDictionary.Instance; + + /// indexed by name, lazily initialized. + private Dictionary? _parametersByName; + + /// Initializes a new instance of the class for a function with the specified name. + /// The name of the function. + /// The was null. + public AIFunctionMetadata(string name) + { + _name = Throw.IfNullOrWhitespace(name); + } + + /// Initializes a new instance of the class as a copy of another . + /// The was null. + /// + /// This creates a shallow clone of . The new instance's and + /// properties will return the same objects as in the original instance. + /// + public AIFunctionMetadata(AIFunctionMetadata metadata) + { + Name = Throw.IfNull(metadata).Name; + Description = metadata.Description; + Parameters = metadata.Parameters; + ReturnParameter = metadata.ReturnParameter; + AdditionalProperties = metadata.AdditionalProperties; + } + + /// Gets the name of the function. + public string Name + { + get => _name; + init => _name = Throw.IfNullOrWhitespace(value); + } + + /// Gets a description of the function, suitable for use in describing the purpose to a model. + [AllowNull] + public string Description + { + get => _description; + init => _description = value ?? string.Empty; + } + + /// Gets the metadata for the parameters to the function. + /// If the function has no parameters, the returned list will be empty. + public IReadOnlyList Parameters + { + get => _parameters; + init => _parameters = Throw.IfNull(value); + } + + /// Gets the for a parameter by its name. + /// The name of the parameter. + /// The corresponding , if found; otherwise, null. + public AIFunctionParameterMetadata? GetParameter(string name) + { + Dictionary? parametersByName = _parametersByName ??= _parameters.ToDictionary(p => p.Name); + + return parametersByName.TryGetValue(name, out AIFunctionParameterMetadata? parameter) ? + parameter : + null; + } + + /// Gets parameter metadata for the return parameter. + /// If the function has no return parameter, the returned value will be a default instance of a . + public AIFunctionReturnParameterMetadata ReturnParameter + { + get => _returnParameter; + init => _returnParameter = Throw.IfNull(value); + } + + /// Gets any additional properties associated with the function. + public IReadOnlyDictionary AdditionalProperties + { + get => _additionalProperties; + init => _additionalProperties = Throw.IfNull(value); + } + + /// Gets a that may be used to marshal function parameters. + public JsonSerializerOptions? JsonSerializerOptions { get; init; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs new file mode 100644 index 00000000000..b9bd4d83841 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs @@ -0,0 +1,67 @@ +// 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.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides read-only metadata for a parameter. +/// +public sealed class AIFunctionParameterMetadata +{ + private string _name; + + /// Initializes a new instance of the class for a parameter with the specified name. + /// The name of the parameter. + /// The was null. + /// The was empty or composed entirely of whitespace. + public AIFunctionParameterMetadata(string name) + { + _name = Throw.IfNullOrWhitespace(name); + } + + /// Initializes a new instance of the class as a copy of another . + /// The was null. + /// This creates a shallow clone of . + public AIFunctionParameterMetadata(AIFunctionParameterMetadata metadata) + { + _ = Throw.IfNull(metadata); + _ = Throw.IfNullOrWhitespace(metadata.Name); + + _name = metadata.Name; + + Description = metadata.Description; + HasDefaultValue = metadata.HasDefaultValue; + DefaultValue = metadata.DefaultValue; + IsRequired = metadata.IsRequired; + ParameterType = metadata.ParameterType; + Schema = metadata.Schema; + } + + /// Gets the name of the parameter. + public string Name + { + get => _name; + init => _name = Throw.IfNullOrWhitespace(value); + } + + /// Gets a description of the parameter, suitable for use in describing the purpose to a model. + public string? Description { get; init; } + + /// Gets a value indicating whether the parameter has a default value. + public bool HasDefaultValue { get; init; } + + /// Gets the default value of the parameter. + public object? DefaultValue { get; init; } + + /// Gets a value indicating whether the parameter is required. + public bool IsRequired { get; init; } + + /// Gets the .NET type of the parameter. + public Type? ParameterType { get; init; } + + /// Gets a JSON Schema describing the parameter's type. + public object? Schema { get; init; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs new file mode 100644 index 00000000000..17aec4d2fdb --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides read-only metadata for a 's return parameter. +/// +public sealed class AIFunctionReturnParameterMetadata +{ + /// Gets an empty return parameter metadata instance. + public static AIFunctionReturnParameterMetadata Empty { get; } = new(); + + /// Initializes a new instance of the class. + public AIFunctionReturnParameterMetadata() + { + } + + /// Initializes a new instance of the class as a copy of another . + public AIFunctionReturnParameterMetadata(AIFunctionReturnParameterMetadata metadata) + { + Description = Throw.IfNull(metadata).Description; + ParameterType = metadata.ParameterType; + Schema = metadata.Schema; + } + + /// Gets a description of the return parameter, suitable for use in describing the purpose to a model. + public string? Description { get; init; } + + /// Gets the .NET type of the return parameter. + public Type? ParameterType { get; init; } + + /// Gets a JSON Schema describing the type of the return parameter. + public object? Schema { get; init; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj new file mode 100644 index 00000000000..4aa2ab89d73 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -0,0 +1,36 @@ + + + + Microsoft.Extensions.AI + Abstractions for generative AI. + AI + + + + preview + 0 + 0 + + + + $(TargetFrameworks);netstandard2.0 + $(NoWarn);CA2227;CA1034;SA1316;S3253 + true + + + + true + true + true + true + + + + + + + + + + + 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 new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md new file mode 100644 index 00000000000..eb9d3a28c6f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md @@ -0,0 +1,481 @@ +# Microsoft.Extensions.AI.Abstractions + +Provides abstractions representing generative AI components. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.AI.Abstractions +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +### `IChatClient` + +The `IChatClient` interface defines a client abstraction responsible for interacting with AI services that provide chat capabilities. It defines methods for sending and receiving messages comprised of multi-modal content (text, images, audio, etc.), either as a complete set or streamed incrementally. Additionally, it provides metadata information about the client and allows for retrieving strongly-typed services that may be provided by the client or its underlying services. + +#### Sample Implementation + +.NET libraries that provide clients for language models and services may provide an implementation of the `IChatClient` interface. Any consumers of the interface are then able to interoperate seamlessly with these models and services via the abstractions. + +Here is a sample implementation of an `IChatClient` to show the general structure. You can find other concrete implementations in the following packages: + +- [Microsoft.Extensions.AI.AzureAIInference](https://aka.ms/meai-azaiinference-nuget) +- [Microsoft.Extensions.AI.OpenAI](https://aka.ms/meai-openai-nuget) +- [Microsoft.Extensions.AI.Ollama](https://aka.ms/meai-ollama-nuget) + +```csharp +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +public class SampleChatClient : IChatClient +{ + public ChatClientMetadata Metadata { get; } + + public SampleChatClient(Uri endpoint, string modelId) => + Metadata = new("SampleChatClient", endpoint, modelId); + + public async Task CompleteAsync( + IList chatMessages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + // Simulate some operation. + await Task.Delay(300, cancellationToken); + + // Return a sample chat completion response randomly. + string[] responses = + [ + "This is the first sample response.", + "Here is another example of a response message.", + "This is yet another response message." + ]; + + return new([new ChatMessage() + { + Role = ChatRole.Assistant, + Text = responses[Random.Shared.Next(responses.Length)], + }]); + } + + public async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Simulate streaming by yielding messages one by one. + string[] words = ["This ", "is ", "the ", "response ", "for ", "the ", "request."]; + foreach (string word in words) + { + // Simulate some operation. + await Task.Delay(100, cancellationToken); + + // Yield the next message in the response. + yield return new StreamingChatCompletionUpdate + { + Role = ChatRole.Assistant, + Text = word, + }; + } + } + + public TService? GetService(object? key = null) where TService : class => + this as TService; + + void IDisposable.Dispose() { } +} +``` + +#### Requesting a Chat Completion: `CompleteAsync` + +With an instance of `IChatClient`, the `CompleteAsync` method may be used to send a request. The request is composed of one or more messages, each of which is composed of one or more pieces of content. Accelerator methods exist to simplify common cases, such as constructing a request for a single piece of text content. + +```csharp +using Microsoft.Extensions.AI; + +IChatClient client = new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model"); + +var response = await client.CompleteAsync("What is AI?"); + +Console.WriteLine(response.Message); +``` + +The core `CompleteAsync` method on the `IChatClient` interface accepts a list of messages. This list represents the history of all messages that are part of the conversation. + +```csharp +using Microsoft.Extensions.AI; + +IChatClient client = new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model"); + +Console.WriteLine(await client.CompleteAsync( +[ + new(ChatRole.System, "You are a helpful AI assistant"), + new(ChatRole.User, "What is AI?"), +])); +``` + +#### Requesting a Streaming Chat Completion: `CompleteStreamingAsync` + +The inputs to `CompleteStreamingAsync` are identical to those of `CompleteAsync`. However, rather than returning the complete response as part of a `ChatCompletion` object, the method returns an `IAsyncEnumerable`, providing a stream of updates that together form the single response. + +```csharp +using Microsoft.Extensions.AI; + +IChatClient client = new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model"); + +await foreach (var update in client.CompleteStreamingAsync("What is AI?")) +{ + Console.Write(update); +} +``` + +#### Tool calling + +Some models and services support the notion of tool calling, where requests may include information about tools that the model may request be invoked in order to gather additional information, in particular functions. Rather than sending back a response message that represents the final response to the input, the model sends back a request to invoke a given function with a given set of arguments; the client may then find and invoke the relevant function and send back the results to the model (along with all the rest of the history). The abstractions in Microsoft.Extensions.AI include representations for various forms of content that may be included in messages, and this includes representations for these function call requests and results. While it's possible for the consumer of the `IChatClient` to interact with this content directly, `Microsoft.Extensions.AI` supports automating these interactions. It provides an `AIFunction` that represents an invocable function along with metadata for describing the function to the AI model, along with an `AIFunctionFactory` for creating `AIFunction`s to represent .NET methods. It also provides a `FunctionInvokingChatClient` that both is an `IChatClient` and also wraps an `IChatClient`, enabling layering automatic function invocation capabilities around an arbitrary `IChatClient` implementation. + +```csharp +using System.ComponentModel; +using Microsoft.Extensions.AI; + +[Description("Gets the current weather")] +string GetCurrentWeather() => Random.Shared.NextDouble() > 0.5 ? "It's sunny" : "It's raining"; + +IChatClient client = new ChatClientBuilder() + .UseFunctionInvocation() + .Use(new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1")); + +var response = client.CompleteStreamingAsync( + "Should I wear a rain coat?", + new() { Tools = [AIFunctionFactory.Create(GetCurrentWeather)] }); + +await foreach (var update in response) +{ + Console.Write(update); +} +``` + +#### Caching + +`Microsoft.Extensions.AI` provides other such delegating `IChatClient` implementations. The `DistributedCachingChatClient` is an `IChatClient` that layers caching around another arbitrary `IChatClient` instance. When a unique chat history that's not been seen before is submitted to the `DistributedCachingChatClient`, it forwards it along to the underlying client, and then caches the response prior to it being forwarded back to the consumer. The next time the same history is submitted, such that a cached response can be found in the cache, the `DistributedCachingChatClient` can return back the cached response rather than needing to forward the request along the pipeline. + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +IChatClient client = new ChatClientBuilder() + .UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) + .Use(new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model")); + +string[] prompts = ["What is AI?", "What is .NET?", "What is AI?"]; + +foreach (var prompt in prompts) +{ + await foreach (var update in client.CompleteStreamingAsync(prompt)) + { + Console.Write(update); + } + Console.WriteLine(); +} +``` + +#### Telemetry + +Other such delegating chat clients are provided as well. The `OpenTelemetryChatClient`, for example, provides an implementation of the [OpenTelemetry Semantic Conventions for Generative AI systems](https://opentelemetry.io/docs/specs/semconv/gen-ai/). As with the aforementioned `IChatClient` delegators, this implementation layers metrics and spans around other arbitrary `IChatClient` implementations. + +```csharp +using Microsoft.Extensions.AI; +using OpenTelemetry.Trace; + +// Configure OpenTelemetry exporter +var sourceName = Guid.NewGuid().ToString(); +var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddConsoleExporter() + .Build(); + +IChatClient client = new ChatClientBuilder() + .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) + .Use(new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model")); + +Console.WriteLine((await client.CompleteAsync("What is AI?")).Message); +``` + +#### Pipelines of Functionality + +All of these `IChatClient`s may be layered, creating a pipeline of any number of components that all add additional functionality. Such components may come from `Microsoft.Extensions.AI`, may come from other NuGet packages, or may be your own custom implementations that augment the behavior in whatever ways you need. + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using OpenTelemetry.Trace; + +// Configure OpenTelemetry exporter +var sourceName = Guid.NewGuid().ToString(); +var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddConsoleExporter() + .Build(); + +// Explore changing the order of the intermediate "Use" calls to see that impact +// that has on what gets cached, traced, etc. +IChatClient client = new ChatClientBuilder() + .UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) + .UseFunctionInvocation() + .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) + .Use(new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1")); + +ChatOptions options = new() +{ + Tools = [AIFunctionFactory.Create( + () => Random.Shared.NextDouble() > 0.5 ? "It's sunny" : "It's raining", + name: "GetCurrentWeather", + description: "Gets the current weather")] +}; + +for (int i = 0; i < 3; i++) +{ + List history = + [ + new ChatMessage(ChatRole.System, "You are a helpful AI assistant"), + new ChatMessage(ChatRole.User, "Do I need an umbrella?") + ]; + + Console.WriteLine(await client.CompleteAsync(history, options)); +} +``` + +#### Custom `IChatClient` Middleware + +Anyone can layer in such additional functionality. While it's possible to implement `IChatClient` directly, the `DelegatingChatClient` class is an implementation of the `IChatClient` interface that serves as a base class for creating chat clients that delegate their operations to another `IChatClient` instance. It is designed to facilitate the chaining of multiple clients, allowing calls to be passed through to an underlying client. The class provides default implementations for methods such as `CompleteAsync`, `CompleteStreamingAsync`, and `Dispose`, simply forwarding the calls to the inner client instance. A derived type may then override just the methods it needs to in order to augment the behavior, delegating to the base implementation in order to forward the call along to the wrapped client. This setup is useful for creating flexible and modular chat clients that can be easily extended and composed. + +Here is an example class derived from `DelegatingChatClient` to provide logging functionality: +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.Runtime.CompilerServices; +using System.Text.Json; + +public sealed class LoggingChatClient(IChatClient innerClient, ILogger? logger = null) : + DelegatingChatClient(innerClient) +{ + public override async Task CompleteAsync( + IList chatMessages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + logger?.LogTrace("Request: {Messages}", chatMessages); + var chatCompletion = await base.CompleteAsync(chatMessages, options, cancellationToken); + logger?.LogTrace("Response: {Completion}", JsonSerializer.Serialize(chatCompletion)); + return chatCompletion; + } + + public override async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + logger?.LogTrace("Request: {Messages}", chatMessages); + await foreach (var update in base.CompleteStreamingAsync(chatMessages, options, cancellationToken)) + { + logger?.LogTrace("Response Update: {Update}", JsonSerializer.Serialize(update)); + yield return update; + } + } +} +``` + +This can then be composed as with other `IChatClient` implementations. + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +var client = new LoggingChatClient( + new SampleChatClient(new Uri("http://localhost"), "test"), + LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)).CreateLogger("AI")); + +await client.CompleteAsync("Hello, world!"); +``` + +#### Dependency Injection + +`IChatClient` implementations will typically be provided to an application via dependency injection (DI). In this example, an `IDistributedCache` is added into the DI container, as is an `IChatClient`. The registration for the `IChatClient` employs a builder that creates a pipeline containing a caching client (which will then use an `IDistributedCache` retrieved from DI) and the sample client. Elsewhere in the app, the injected `IChatClient` may be retrieved and used. + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using System.Runtime.CompilerServices; + +// App Setup +var builder = Host.CreateApplicationBuilder(); +builder.Services.AddSingleton( + new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))); +builder.Services.AddChatClient(b => b + .UseDistributedCache() + .Use(new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model"))); +var host = builder.Build(); + +// Elsewhere in the app +var chatClient = host.Services.GetRequiredService(); +Console.WriteLine(await chatClient.CompleteAsync("What is AI?")); +``` + +What instance and configuration is injected may differ based on the current needs of the application, and multiple pipelines may be injected with different keys. + +### IEmbeddingGenerator + +The `IEmbeddingGenerator` interface represents a generic generator of embeddings, where `TInput` is the type of input values being embedded and `TEmbedding` is the type of generated embedding, inheriting from `Embedding`. + +The `Embedding` class provides a base class for embeddings generated by an `IEmbeddingGenerator`. This class is designed to store and manage the metadata and data associated with embeddings. Types derived from `Embedding`, like `Embedding`, then provide the concrete embedding vector data. For example, an `Embedding` exposes a `ReadOnlyMemory Vector { get; }` property for access to its embedding data. + +`IEmbeddingGenerator` defines a method to asynchronously generate embeddings for a collection of input values with optional configuration and cancellation support. Additionally, it provides metadata describing the generator and allows for the retrieval of strongly-typed services that may be provided by the generator or its underlying services. + +#### Sample Implementation + +Here is a sample implementation of an `IEmbeddingGenerator` to show the general structure but that just generates random embedding vectors. You can find actual concrete implementations in the following packages: + +- [Microsoft.Extensions.AI.OpenAI](https://aka.ms/meai-openai-nuget) +- [Microsoft.Extensions.AI.Ollama](https://aka.ms/meai-ollama-nuget) + +```csharp +using Microsoft.Extensions.AI; + +public class SampleEmbeddingGenerator(Uri endpoint, string modelId) : IEmbeddingGenerator> +{ + public EmbeddingGeneratorMetadata Metadata { get; } = new("SampleEmbeddingGenerator", endpoint, modelId); + + public async Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + // Simulate some async operation + await Task.Delay(100, cancellationToken); + + // Create random embeddings + return new GeneratedEmbeddings>( + from value in values + select new Embedding( + Enumerable.Range(0, 384).Select(_ => Random.Shared.NextSingle()).ToArray())); + } + + public TService? GetService(object? key = null) where TService : class => + this as TService; + + void IDisposable.Dispose() { } +} +``` + +#### Creating an embedding: `GenerateAsync` + +The primary operation performed with an `IEmbeddingGenerator` is generating embeddings, which is accomplished with its `GenerateAsync` method. + +```csharp +using Microsoft.Extensions.AI; + +IEmbeddingGenerator> generator = + new SampleEmbeddingGenerator(new Uri("http://coolsite.ai"), "my-custom-model"); + +foreach (var embedding in await generator.GenerateAsync(["What is AI?", "What is .NET?"])) +{ + Console.WriteLine(string.Join(", ", embedding.Vector.ToArray())); +} +``` + +#### Middleware + +As with `IChatClient`, `IEmbeddingGenerator` implementations may be layered. Just as `Microsoft.Extensions.AI` provides delegating implementations of `IChatClient` for caching and telemetry, it does so for `IEmbeddingGenerator` as well. + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using OpenTelemetry.Trace; + +// Configure OpenTelemetry exporter +var sourceName = Guid.NewGuid().ToString(); +var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddConsoleExporter() + .Build(); + +// Explore changing the order of the intermediate "Use" calls to see that impact +// that has on what gets cached, traced, etc. +IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>() + .UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) + .UseOpenTelemetry(sourceName) + .Use(new SampleEmbeddingGenerator(new Uri("http://coolsite.ai"), "my-custom-model")); + +var embeddings = await generator.GenerateAsync( +[ + "What is AI?", + "What is .NET?", + "What is AI?" +]); + +foreach (var embedding in embeddings) +{ + Console.WriteLine(string.Join(", ", embedding.Vector.ToArray())); +} +``` + +Also as with `IChatClient`, `IEmbeddingGenerator` enables building custom middleware that extends the functionality of an `IEmbeddingGenerator`. The `DelegatingEmbeddingGenerator` class is an implementation of the `IEmbeddingGenerator` interface that serves as a base class for creating embedding generators which delegate their operations to another `IEmbeddingGenerator` instance. It allows for chaining multiple generators in any order, passing calls through to an underlying generator. The class provides default implementations for methods such as `GenerateAsync` and `Dispose`, which simply forward the calls to the inner generator instance, enabling flexible and modular embedding generation. + +Here is an example implementation of such a delegating embedding generator that logs embedding generation requests: +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +public class LoggingEmbeddingGenerator(IEmbeddingGenerator> innerGenerator, ILogger? logger = null) : + DelegatingEmbeddingGenerator>(innerGenerator) +{ + public override Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + logger?.LogInformation("Generating embeddings for {Count} values", values.Count()); + return base.GenerateAsync(values, options, cancellationToken); + } +} +``` + +This can then be layered around an arbitrary `IEmbeddingGenerator>` to log all embedding generation operations performed. + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +IEmbeddingGenerator> generator = + new LoggingEmbeddingGenerator( + new SampleEmbeddingGenerator(new Uri("http://coolsite.ai"), "my-custom-model"), + LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)).CreateLogger("AI")); + +foreach (var embedding in await generator.GenerateAsync(["What is AI?", "What is .NET?"])) +{ + Console.WriteLine(string.Join(", ", embedding.Vector.ToArray())); +} +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs new file mode 100644 index 00000000000..f12ed819a6e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides usage details about a request/response. +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public class UsageDetails +{ + /// Gets or sets the number of tokens in the input. + public int? InputTokenCount { get; set; } + + /// Gets or sets the number of tokens in the output. + public int? OutputTokenCount { get; set; } + + /// Gets or sets the total number of tokens used to produce the response. + public int? TotalTokenCount { get; set; } + + /// Gets or sets additional properties for the usage details. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Gets a string representing this instance to display in the debugger. + internal string DebuggerDisplay + { + get + { + List parts = []; + + if (InputTokenCount is int input) + { + parts.Add($"{nameof(InputTokenCount)} = {input}"); + } + + if (OutputTokenCount is int output) + { + parts.Add($"{nameof(OutputTokenCount)} = {output}"); + } + + if (TotalTokenCount is int total) + { + parts.Add($"{nameof(TotalTokenCount)} = {total}"); + } + + if (AdditionalProperties is { } additionalProperties) + { + foreach (var entry in additionalProperties) + { + parts.Add($"{entry.Key} = {entry.Value}"); + } + } + + return string.Join(", ", parts); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs new file mode 100644 index 00000000000..cccd9f04caf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -0,0 +1,495 @@ +// 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.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Inference; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S1135 // Track uses of "TODO" tags +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace Microsoft.Extensions.AI; + +/// An for an Azure AI Inference . +public sealed partial class AzureAIInferenceChatClient : IChatClient +{ + /// The underlying . + private readonly ChatCompletionsClient _chatCompletionsClient; + + /// Initializes a new instance of the class for the specified . + /// The underlying client. + /// The id of the model to use. If null, it may be provided per request via . + public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, string? modelId = null) + { + _ = Throw.IfNull(chatCompletionsClient); + if (modelId is not null) + { + _ = Throw.IfNullOrWhitespace(modelId); + } + + _chatCompletionsClient = chatCompletionsClient; + + // https://github.com/Azure/azure-sdk-for-net/issues/46278 + // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages + // 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. + var providerUrl = typeof(ChatCompletionsClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(chatCompletionsClient) as Uri; + + Metadata = new("AzureAIInference", providerUrl, modelId); + } + + /// Gets or sets to use for any serialization activities related to tool call arguments and results. + public JsonSerializerOptions? ToolCallJsonSerializerOptions { get; set; } + + /// + public ChatClientMetadata Metadata { get; } + + /// + public TService? GetService(object? key = null) + where TService : class => + typeof(TService) == typeof(ChatCompletionsClient) ? (TService?)(object?)_chatCompletionsClient : + this as TService; + + /// + public async Task CompleteAsync( + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + // Make the call. + ChatCompletions response = (await _chatCompletionsClient.CompleteAsync( + ToAzureAIOptions(chatMessages, options), + cancellationToken: cancellationToken).ConfigureAwait(false)).Value; + + // Create the return message. + List returnMessages = []; + + // Populate its content from those in the response content. + ChatFinishReason? finishReason = null; + foreach (var choice in response.Choices) + { + ChatMessage returnMessage = new() + { + RawRepresentation = choice, + Role = ToChatRole(choice.Message.Role), + AdditionalProperties = new() { [nameof(choice.Index)] = choice.Index }, + }; + + finishReason ??= ToFinishReason(choice.FinishReason); + + if (choice.Message.ToolCalls is { Count: > 0 } toolCalls) + { + foreach (var toolCall in toolCalls) + { + if (toolCall is ChatCompletionsFunctionToolCall ftc && !string.IsNullOrWhiteSpace(ftc.Name)) + { + Dictionary? arguments = FunctionCallHelpers.ParseFunctionCallArguments(ftc.Arguments, out Exception? parsingException); + + returnMessage.Contents.Add(new FunctionCallContent(toolCall.Id, ftc.Name, arguments) + { + ModelId = response.Model, + Exception = parsingException, + RawRepresentation = toolCall + }); + } + } + } + + if (!string.IsNullOrEmpty(choice.Message.Content)) + { + returnMessage.Contents.Add(new TextContent(choice.Message.Content) + { + ModelId = response.Model, + RawRepresentation = choice.Message + }); + } + + returnMessages.Add(returnMessage); + } + + UsageDetails? usage = null; + if (response.Usage is CompletionsUsage completionsUsage) + { + usage = new() + { + InputTokenCount = completionsUsage.PromptTokens, + OutputTokenCount = completionsUsage.CompletionTokens, + TotalTokenCount = completionsUsage.TotalTokens, + }; + } + + // Wrap the content in a ChatCompletion to return. + return new ChatCompletion(returnMessages) + { + RawRepresentation = response, + CompletionId = response.Id, + CreatedAt = response.Created, + ModelId = response.Model, + FinishReason = finishReason, + Usage = usage, + }; + } + + /// + public async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + Dictionary? functionCallInfos = null; + ChatRole? streamedRole = default; + ChatFinishReason? finishReason = default; + string? completionId = null; + DateTimeOffset? createdAt = null; + string? modelId = null; + string? authorName = null; + + // Process each update as it arrives + var updates = await _chatCompletionsClient.CompleteStreamingAsync(ToAzureAIOptions(chatMessages, options), cancellationToken).ConfigureAwait(false); + await foreach (StreamingChatCompletionsUpdate chatCompletionUpdate in updates.ConfigureAwait(false)) + { + // The role and finish reason may arrive during any update, but once they've arrived, the same value should be the same for all subsequent updates. + streamedRole ??= chatCompletionUpdate.Role is global::Azure.AI.Inference.ChatRole role ? ToChatRole(role) : null; + finishReason ??= chatCompletionUpdate.FinishReason is CompletionsFinishReason reason ? ToFinishReason(reason) : null; + completionId ??= chatCompletionUpdate.Id; + createdAt ??= chatCompletionUpdate.Created; + modelId ??= chatCompletionUpdate.Model; + authorName ??= chatCompletionUpdate.AuthorName; + + // Create the response content object. + StreamingChatCompletionUpdate completionUpdate = new() + { + AuthorName = authorName, + CompletionId = chatCompletionUpdate.Id, + CreatedAt = chatCompletionUpdate.Created, + FinishReason = finishReason, + RawRepresentation = chatCompletionUpdate, + Role = streamedRole, + }; + + // Transfer over content update items. + if (chatCompletionUpdate.ContentUpdate is string update) + { + completionUpdate.Contents.Add(new TextContent(update) + { + ModelId = modelId, + }); + } + + // Transfer over tool call updates. + if (chatCompletionUpdate.ToolCallUpdate is StreamingFunctionToolCallUpdate toolCallUpdate) + { + functionCallInfos ??= []; + if (!functionCallInfos.TryGetValue(toolCallUpdate.ToolCallIndex, out FunctionCallInfo? existing)) + { + functionCallInfos[toolCallUpdate.ToolCallIndex] = existing = new(); + } + + existing.CallId ??= toolCallUpdate.Id; + existing.Name ??= toolCallUpdate.Name; + if (toolCallUpdate.ArgumentsUpdate is not null) + { + _ = (existing.Arguments ??= new()).Append(toolCallUpdate.ArgumentsUpdate); + } + } + + // Now yield the item. + yield return completionUpdate; + } + + // TODO: Add usage as content when it's exposed by Azure.AI.Inference. + + // Now that we've received all updates, combine any for function calls into a single item to yield. + if (functionCallInfos is not null) + { + var completionUpdate = new StreamingChatCompletionUpdate + { + AuthorName = authorName, + CompletionId = completionId, + CreatedAt = createdAt, + FinishReason = finishReason, + Role = streamedRole, + }; + + foreach (var entry in functionCallInfos) + { + FunctionCallInfo fci = entry.Value; + if (!string.IsNullOrWhiteSpace(fci.Name)) + { + var arguments = FunctionCallHelpers.ParseFunctionCallArguments( + fci.Arguments?.ToString() ?? string.Empty, + out Exception? parsingException); + + completionUpdate.Contents.Add(new FunctionCallContent(fci.CallId!, fci.Name!, arguments) + { + ModelId = modelId, + Exception = parsingException + }); + } + } + + yield return completionUpdate; + } + } + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IChatClient interface. + } + + /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. + private sealed class FunctionCallInfo + { + public string? CallId; + public string? Name; + public StringBuilder? Arguments; + } + + /// Converts an AzureAI role to an Extensions role. + private static ChatRole ToChatRole(global::Azure.AI.Inference.ChatRole role) => + role.Equals(global::Azure.AI.Inference.ChatRole.System) ? ChatRole.System : + role.Equals(global::Azure.AI.Inference.ChatRole.User) ? ChatRole.User : + role.Equals(global::Azure.AI.Inference.ChatRole.Assistant) ? ChatRole.Assistant : + role.Equals(global::Azure.AI.Inference.ChatRole.Tool) ? ChatRole.Tool : + new ChatRole(role.ToString()); + + /// Converts an AzureAI finish reason to an Extensions finish reason. + private static ChatFinishReason? ToFinishReason(CompletionsFinishReason? finishReason) => + finishReason?.ToString() is not string s ? null : + finishReason == CompletionsFinishReason.Stopped ? ChatFinishReason.Stop : + finishReason == CompletionsFinishReason.TokenLimitReached ? ChatFinishReason.Length : + finishReason == CompletionsFinishReason.ContentFiltered ? ChatFinishReason.ContentFilter : + finishReason == CompletionsFinishReason.ToolCalls ? ChatFinishReason.ToolCalls : + new(s); + + /// Converts an extensions options instance to an AzureAI options instance. + private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, ChatOptions? options) + { + ChatCompletionsOptions result = new(ToAzureAIInferenceChatMessages(chatContents)) + { + Model = options?.ModelId ?? Metadata.ModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.") + }; + + if (options is not null) + { + result.FrequencyPenalty = options.FrequencyPenalty; + result.MaxTokens = options.MaxOutputTokens; + result.NucleusSamplingFactor = options.TopP; + result.PresencePenalty = options.PresencePenalty; + result.Temperature = options.Temperature; + + if (options.StopSequences is { Count: > 0 } stopSequences) + { + foreach (string stopSequence in stopSequences) + { + result.StopSequences.Add(stopSequence); + } + } + + if (options.AdditionalProperties is { } props) + { + foreach (var prop in props) + { + switch (prop.Key) + { + // These properties are strongly-typed on the ChatCompletionsOptions class. + case nameof(result.Seed) when prop.Value is long seed: + result.Seed = seed; + break; + + // Propagate everything else to the ChatCompletionOptions' AdditionalProperties. + default: + if (prop.Value is not null) + { + result.AdditionalProperties[prop.Key] = BinaryData.FromObjectAsJson(prop.Value, ToolCallJsonSerializerOptions); + } + + break; + } + } + } + + if (options.Tools is { Count: > 0 } tools) + { + foreach (AITool tool in tools) + { + if (tool is AIFunction af) + { + result.Tools.Add(ToAzureAIChatTool(af)); + } + } + + switch (options.ToolMode) + { + case AutoChatToolMode: + result.ToolChoice = ChatCompletionsToolChoice.Auto; + break; + + case RequiredChatToolMode required: + result.ToolChoice = required.RequiredFunctionName is null ? + ChatCompletionsToolChoice.Required : + new ChatCompletionsToolChoice(new FunctionDefinition(required.RequiredFunctionName)); + break; + } + } + + if (options.ResponseFormat is ChatResponseFormatText) + { + result.ResponseFormat = new ChatCompletionsResponseFormatText(); + } + else if (options.ResponseFormat is ChatResponseFormatJson) + { + result.ResponseFormat = new ChatCompletionsResponseFormatJSON(); + } + } + + return result; + } + + /// Converts an Extensions function to an AzureAI chat tool. + private ChatCompletionsFunctionToolDefinition ToAzureAIChatTool(AIFunction aiFunction) + { + BinaryData resultParameters = AzureAIChatToolJson.ZeroFunctionParametersSchema; + + var parameters = aiFunction.Metadata.Parameters; + if (parameters is { Count: > 0 }) + { + AzureAIChatToolJson tool = new(); + + foreach (AIFunctionParameterMetadata parameter in parameters) + { + tool.Properties.Add( + parameter.Name, + FunctionCallHelpers.InferParameterJsonSchema(parameter, aiFunction.Metadata, ToolCallJsonSerializerOptions)); + + if (parameter.IsRequired) + { + tool.Required.Add(parameter.Name); + } + } + + resultParameters = BinaryData.FromBytes( + JsonSerializer.SerializeToUtf8Bytes(tool, JsonContext.Default.AzureAIChatToolJson)); + } + + return new() + { + Name = aiFunction.Metadata.Name, + Description = aiFunction.Metadata.Description, + Parameters = resultParameters, + }; + } + + /// Used to create the JSON payload for an AzureAI chat tool description. + private sealed class AzureAIChatToolJson + { + /// Gets a singleton JSON data for empty parameters. Optimization for the reasonably common case of a parameterless function. + public static BinaryData ZeroFunctionParametersSchema { get; } = new("""{"type":"object","required":[],"properties":{}}"""u8.ToArray()); + + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public List Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + } + + /// Converts an Extensions chat message enumerable to an AzureAI chat message enumerable. + private IEnumerable ToAzureAIInferenceChatMessages(IEnumerable inputs) + { + // Maps all of the M.E.AI types to the corresponding AzureAI types. + // Unrecognized content is ignored. + + foreach (ChatMessage input in inputs) + { + if (input.Role == ChatRole.System) + { + yield return new ChatRequestSystemMessage(input.Text); + } + else if (input.Role == ChatRole.Tool) + { + foreach (AIContent item in input.Contents) + { + if (item is FunctionResultContent resultContent) + { + string? result = resultContent.Result as string; + if (result is null && resultContent.Result is not null) + { + try + { + result = FunctionCallHelpers.FormatFunctionResultAsJson(resultContent.Result, ToolCallJsonSerializerOptions); + } + catch (NotSupportedException) + { + // If the type can't be serialized, skip it. + } + } + + yield return new ChatRequestToolMessage(result ?? string.Empty, resultContent.CallId); + } + } + } + else if (input.Role == ChatRole.User) + { + yield return new ChatRequestUserMessage(input.Contents.Select(static (AIContent item) => item switch + { + TextContent textContent => new ChatMessageTextContentItem(textContent.Text), + ImageContent imageContent => imageContent.Data is { IsEmpty: false } data ? new ChatMessageImageContentItem(BinaryData.FromBytes(data), imageContent.MediaType) : + imageContent.Uri is string uri ? new ChatMessageImageContentItem(new Uri(uri)) : + (ChatMessageContentItem?)null, + _ => null, + }).Where(c => c is not null)); + } + else if (input.Role == ChatRole.Assistant) + { + Dictionary? toolCalls = null; + + foreach (var content in input.Contents) + { + if (content is FunctionCallContent callRequest && callRequest.CallId is not null && toolCalls?.ContainsKey(callRequest.CallId) is not true) + { + string jsonArguments = FunctionCallHelpers.FormatFunctionParametersAsJson(callRequest.Arguments, ToolCallJsonSerializerOptions); + (toolCalls ??= []).Add( + callRequest.CallId, + new ChatCompletionsFunctionToolCall( + callRequest.CallId, + callRequest.Name, + jsonArguments)); + } + } + + ChatRequestAssistantMessage message = new(); + if (toolCalls is not null) + { + foreach (var entry in toolCalls) + { + message.ToolCalls.Add(entry.Value); + } + } + else + { + message.Content = input.Text; + } + + yield return message; + } + } + } + + /// Source-generated JSON type information. + [JsonSerializable(typeof(AzureAIChatToolJson))] + private sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs new file mode 100644 index 00000000000..d8ba7616316 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.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 Azure.AI.Inference; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for working with Azure AI Inference. +public static class AzureAIInferenceExtensions +{ + /// Gets an for use with this . + /// The client. + /// The id of the model to use. If null, it may be provided per request via . + /// An that may be used to converse via the . + public static IChatClient AsChatClient(this ChatCompletionsClient chatCompletionsClient, string? modelId = null) => + new AzureAIInferenceChatClient(chatCompletionsClient, modelId); +} 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 new file mode 100644 index 00000000000..d1f802ace8a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj @@ -0,0 +1,43 @@ + + + + Microsoft.Extensions.AI + Implementation of generative AI abstractions for Azure.AI.Inference. + AI + + + + preview + 0 + 0 + + + + $(TargetFrameworks);netstandard2.0 + $(NoWarn);CA1063;CA2227;SA1316;S1067;S1121;S3358 + true + + + + true + true + true + true + true + + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.json b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md new file mode 100644 index 00000000000..3fd34c7897b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md @@ -0,0 +1,283 @@ +# Microsoft.Extensions.AI.AzureAIInference + +Provides an implementation of the `IChatClient` interface for the `Azure.AI.Inference` package. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.AI.AzureAIInference +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +### Chat + +```csharp +using Azure; +using Microsoft.Extensions.AI; + +IChatClient client = + new Azure.AI.Inference.ChatCompletionsClient( + new("https://models.inference.ai.azure.com"), + new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) + .AsChatClient("gpt-4o-mini"); + +Console.WriteLine(await client.CompleteAsync("What is AI?")); +``` + +### Chat + Conversation History + +```csharp +using Azure; +using Microsoft.Extensions.AI; + +IChatClient client = + new Azure.AI.Inference.ChatCompletionsClient( + new("https://models.inference.ai.azure.com"), + new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) + .AsChatClient("gpt-4o-mini"); + +Console.WriteLine(await client.CompleteAsync( +[ + new ChatMessage(ChatRole.System, "You are a helpful AI assistant"), + new ChatMessage(ChatRole.User, "What is AI?"), +])); +``` + +### Chat streaming + +```csharp +using Azure; +using Microsoft.Extensions.AI; + +IChatClient client = + new Azure.AI.Inference.ChatCompletionsClient( + new("https://models.inference.ai.azure.com"), + new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) + .AsChatClient("gpt-4o-mini"); + +await foreach (var update in client.CompleteStreamingAsync("What is AI?")) +{ + Console.Write(update); +} +``` + +### Tool calling + +```csharp +using System.ComponentModel; +using Azure; +using Microsoft.Extensions.AI; + +IChatClient azureClient = + new Azure.AI.Inference.ChatCompletionsClient( + new("https://models.inference.ai.azure.com"), + new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) + .AsChatClient("gpt-4o-mini"); + +IChatClient client = new ChatClientBuilder() + .UseFunctionInvocation() + .Use(azureClient); + +ChatOptions chatOptions = new() +{ + Tools = [AIFunctionFactory.Create(GetWeather)] +}; + +await foreach (var message in client.CompleteStreamingAsync("Do I need an umbrella?", chatOptions)) +{ + Console.Write(message); +} + +[Description("Gets the weather")] +static string GetWeather() => Random.Shared.NextDouble() > 0.5 ? "It's sunny" : "It's raining"; +``` + +### Caching + +```csharp +using Azure; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + +IChatClient azureClient = + new Azure.AI.Inference.ChatCompletionsClient( + new("https://models.inference.ai.azure.com"), + new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) + .AsChatClient("gpt-4o-mini"); + +IChatClient client = new ChatClientBuilder() + .UseDistributedCache(cache) + .Use(azureClient); + +for (int i = 0; i < 3; i++) +{ + await foreach (var message in client.CompleteStreamingAsync("In less than 100 words, what is AI?")) + { + Console.Write(message); + } + + Console.WriteLine(); + Console.WriteLine(); +} +``` + +### Telemetry + +```csharp +using Azure; +using Microsoft.Extensions.AI; +using OpenTelemetry.Trace; + +// Configure OpenTelemetry exporter +var sourceName = Guid.NewGuid().ToString(); +var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddConsoleExporter() + .Build(); + +IChatClient azureClient = + new Azure.AI.Inference.ChatCompletionsClient( + new("https://models.inference.ai.azure.com"), + new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) + .AsChatClient("gpt-4o-mini"); + +IChatClient client = new ChatClientBuilder() + .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) + .Use(azureClient); + +Console.WriteLine(await client.CompleteAsync("What is AI?")); +``` + +### Telemetry, Caching, and Tool Calling + +```csharp +using System.ComponentModel; +using Azure; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using OpenTelemetry.Trace; + +// Configure telemetry +var sourceName = Guid.NewGuid().ToString(); +var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddConsoleExporter() + .Build(); + +// Configure caching +IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + +// Configure tool calling +var chatOptions = new ChatOptions +{ + Tools = [AIFunctionFactory.Create(GetPersonAge)] +}; + +IChatClient azureClient = + new Azure.AI.Inference.ChatCompletionsClient( + new("https://models.inference.ai.azure.com"), + new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) + .AsChatClient("gpt-4o-mini"); + +IChatClient client = new ChatClientBuilder() + .UseDistributedCache(cache) + .UseFunctionInvocation() + .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) + .Use(azureClient); + +for (int i = 0; i < 3; i++) +{ + Console.WriteLine(await client.CompleteAsync("How much older is Alice than Bob?", chatOptions)); +} + +[Description("Gets the age of a person specified by name.")] +static int GetPersonAge(string personName) => + personName switch + { + "Alice" => 42, + "Bob" => 35, + _ => 26, + }; +``` + +### Dependency Injection + +```csharp +using Azure; +using Azure.AI.Inference; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +// App Setup +var builder = Host.CreateApplicationBuilder(); +builder.Services.AddSingleton( + new ChatCompletionsClient( + new("https://models.inference.ai.azure.com"), + new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!))); +builder.Services.AddSingleton(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))); +builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); + +builder.Services.AddChatClient(b => b + .UseDistributedCache() + .UseLogging() + .Use(b.Services.GetRequiredService().AsChatClient("gpt-4o-mini"))); + +var app = builder.Build(); + +// Elsewhere in the app +var chatClient = app.Services.GetRequiredService(); +Console.WriteLine(await chatClient.CompleteAsync("What is AI?")); +``` + +### Minimal Web API + +```csharp +using Azure; +using Azure.AI.Inference; +using Microsoft.Extensions.AI; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(new ChatCompletionsClient( + new("https://models.inference.ai.azure.com"), + new AzureKeyCredential(builder.Configuration["GH_TOKEN"]!))); + +builder.Services.AddChatClient(b => + b.Use(b.Services.GetRequiredService().AsChatClient("gpt-4o-mini"))); + +var app = builder.Build(); + +app.MapPost("/chat", async (IChatClient client, string message) => +{ + var response = await client.CompleteAsync(message); + return response.Message; +}); + +app.Run(); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs new file mode 100644 index 00000000000..6de0144c7cf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs @@ -0,0 +1,24 @@ +// 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 new file mode 100644 index 00000000000..ac0abe33c10 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj @@ -0,0 +1,47 @@ + + + + Microsoft.Extensions.AI + Implementation of generative AI abstractions for Ollama. + AI + + + + preview + 0 + 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 new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs new file mode 100644 index 00000000000..61827d45cc9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -0,0 +1,408 @@ +// 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.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.Collections; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) + +namespace Microsoft.Extensions.AI; + +/// An for Ollama. +public sealed class OllamaChatClient : IChatClient +{ + /// The api/chat endpoint URI. + private readonly Uri _apiChatEndpoint; + + /// 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 may 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(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("ollama", endpoint, modelId); + } + + /// + public ChatClientMetadata Metadata { get; } + + /// Gets or sets to use for any serialization activities related to tool call arguments and results. + public JsonSerializerOptions? ToolCallJsonSerializerOptions { get; set; } + + /// + public async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + using var httpResponse = await _httpClient.PostAsJsonAsync( + _apiChatEndpoint, + ToOllamaChatRequest(chatMessages, options, stream: false), + JsonContext.Default.OllamaChatRequest, + 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}"); + } + + return new([FromOllamaMessage(response.Message!)]) + { + CompletionId = response.CreatedAt, + ModelId = response.Model ?? options?.ModelId ?? Metadata.ModelId, + CreatedAt = DateTimeOffset.TryParse(response.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, + AdditionalProperties = ParseOllamaChatResponseProps(response), + FinishReason = ToFinishReason(response), + Usage = ParseOllamaChatResponseUsage(response), + }; + } + + /// + public async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + if (options?.Tools is { Count: > 0 }) + { + // We can actually make it work by using the /generate endpoint like the eShopSupport sample does, + // but it's complicated. Really it should be Ollama's job to support this. + throw new NotSupportedException( + "Currently, Ollama does not support function calls in streaming mode. " + + "See Ollama docs at https://github.com/ollama/ollama/blob/main/docs/api.md#parameters-1 to see whether support has since been added."); + } + + using HttpRequestMessage request = new(HttpMethod.Post, _apiChatEndpoint) + { + Content = JsonContent.Create(ToOllamaChatRequest(chatMessages, options, stream: true), JsonContext.Default.OllamaChatRequest) + }; + using var httpResponse = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + using var httpResponseStream = await httpResponse.Content +#if NET + .ReadAsStreamAsync(cancellationToken) +#else + .ReadAsStreamAsync() +#endif + .ConfigureAwait(false); + + await foreach (OllamaChatResponse? chunk in JsonSerializer.DeserializeAsyncEnumerable( + httpResponseStream, + JsonContext.Default.OllamaChatResponse, + topLevelValues: true, + cancellationToken).ConfigureAwait(false)) + { + if (chunk is null) + { + continue; + } + + StreamingChatCompletionUpdate update = new() + { + Role = chunk.Message?.Role is not null ? new ChatRole(chunk.Message.Role) : null, + CreatedAt = DateTimeOffset.TryParse(chunk.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, + AdditionalProperties = ParseOllamaChatResponseProps(chunk), + FinishReason = ToFinishReason(chunk), + }; + + string? modelId = chunk.Model ?? Metadata.ModelId; + + if (chunk.Message is { } message) + { + update.Contents.Add(new TextContent(message.Content) { ModelId = modelId }); + } + + if (ParseOllamaChatResponseUsage(chunk) is { } usage) + { + update.Contents.Add(new UsageContent(usage) { ModelId = modelId }); + } + + yield return update; + } + } + + /// + public TService? GetService(object? key = null) + where TService : class + => key is null ? this as TService : null; + + /// + public void Dispose() + { + if (_httpClient != OllamaUtilities.SharedClient) + { + _httpClient.Dispose(); + } + } + + private static UsageDetails? ParseOllamaChatResponseUsage(OllamaChatResponse response) + { + if (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(), + }; + } + + return null; + } + + private static AdditionalPropertiesDictionary? ParseOllamaChatResponseProps(OllamaChatResponse response) + { + AdditionalPropertiesDictionary? metadata = null; + + OllamaUtilities.TransferNanosecondsTime(response, static r => r.LoadDuration, "load_duration", ref metadata); + OllamaUtilities.TransferNanosecondsTime(response, static r => r.TotalDuration, "total_duration", ref metadata); + OllamaUtilities.TransferNanosecondsTime(response, static r => r.PromptEvalDuration, "prompt_eval_duration", ref metadata); + OllamaUtilities.TransferNanosecondsTime(response, static r => r.EvalDuration, "eval_duration", ref metadata); + + return metadata; + } + + 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) + { + List contents = []; + + // Add any tool calls. + if (message.ToolCalls is { Length: > 0 }) + { + foreach (var toolCall in message.ToolCalls) + { + if (toolCall.Function is { } function) + { + var id = Guid.NewGuid().ToString().Substring(0, 8); + contents.Add(new FunctionCallContent(id, function.Name, function.Arguments)); + } + } + } + + // 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)); + } + + return new ChatMessage(new(message.Role), contents); + } + + private OllamaChatRequest ToOllamaChatRequest(IList chatMessages, ChatOptions? options, bool stream) + { + OllamaChatRequest request = new() + { + Format = options?.ResponseFormat is ChatResponseFormatJson ? "json" : null, + Messages = chatMessages.SelectMany(ToOllamaChatRequestMessages).ToArray(), + Model = options?.ModelId ?? Metadata.ModelId ?? string.Empty, + Stream = stream, + Tools = 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.seed), (options, value) => options.seed = value); + TransferMetadataValue(nameof(OllamaRequestOptions.tfs_z), (options, value) => options.tfs_z = value); + TransferMetadataValue(nameof(OllamaRequestOptions.top_k), (options, value) => options.top_k = 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; + } + } + + return request; + + void TransferMetadataValue(string propertyName, Action setOption) + { + if (options.AdditionalProperties?.TryGetConvertedValue(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 (currentTextMessage is not null && item is not ImageContent) + { + yield return currentTextMessage; + currentTextMessage = null; + } + + switch (item) + { + case TextContent textContent: + currentTextMessage = new OllamaChatRequestMessage + { + Role = content.Role.Value, + Content = textContent.Text ?? string.Empty, + }; + break; + + case ImageContent imageContent when imageContent.Data is not null: + IList images = currentTextMessage?.Images ?? []; + images.Add(Convert.ToBase64String(imageContent.Data.Value +#if NET + .Span)); +#else + .ToArray())); +#endif + + if (currentTextMessage is not null) + { + currentTextMessage.Images = images; + } + else + { + yield return new OllamaChatRequestMessage + { + Role = content.Role.Value, + Images = images, + }; + } + + break; + + case FunctionCallContent fcc: + yield return new OllamaChatRequestMessage + { + Role = "assistant", + Content = JsonSerializer.Serialize(new OllamaFunctionCallContent + { + CallId = fcc.CallId, + Name = fcc.Name, + Arguments = FunctionCallHelpers.FormatFunctionParametersAsJsonElement(fcc.Arguments, ToolCallJsonSerializerOptions), + }, JsonContext.Default.OllamaFunctionCallContent) + }; + break; + + case FunctionResultContent frc: + JsonElement jsonResult = FunctionCallHelpers.FormatFunctionResultAsJsonElement(frc.Result, ToolCallJsonSerializerOptions); + 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 OllamaTool ToOllamaTool(AIFunction function) => new() + { + Type = "function", + Function = new OllamaFunctionTool + { + Name = function.Metadata.Name, + Description = function.Metadata.Description, + Parameters = new OllamaFunctionToolParameters + { + Properties = function.Metadata.Parameters.ToDictionary( + p => p.Name, + p => FunctionCallHelpers.InferParameterJsonSchema(p, function.Metadata, ToolCallJsonSerializerOptions)), + Required = function.Metadata.Parameters.Where(p => p.IsRequired).Select(p => p.Name).ToList(), + }, + } + }; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs new file mode 100644 index 00000000000..5d2f63ddfe5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.Extensions.AI; + +internal sealed class OllamaChatRequest +{ + public required string Model { get; set; } + public required OllamaChatRequestMessage[] Messages { get; set; } + public string? 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 new file mode 100644 index 00000000000..5a377b1eb34 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs @@ -0,0 +1,13 @@ +// 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 new file mode 100644 index 00000000000..8c39f9ab598 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs @@ -0,0 +1,20 @@ +// 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 new file mode 100644 index 00000000000..bf73c08d793 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +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 new file mode 100644 index 00000000000..b0ecf08895c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs @@ -0,0 +1,137 @@ +// 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.Collections; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// An for Ollama. +public sealed class OllamaEmbeddingGenerator : IEmbeddingGenerator> +{ + /// 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 may 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(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); + } + + /// + public EmbeddingGeneratorMetadata Metadata { get; } + + /// + public TService? GetService(object? key = null) + where TService : class + => key is null ? this as TService : 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.ModelId; + var request = new OllamaEmbeddingRequest + { + Model = requestModel ?? string.Empty, + Input = inputs, + }; + + if (options?.AdditionalProperties is { } requestProps) + { + if (requestProps.TryGetConvertedValue("keep_alive", out long keepAlive)) + { + request.KeepAlive = keepAlive; + } + + if (requestProps.TryGetConvertedValue("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); + + 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? responseProps = null; + OllamaUtilities.TransferNanosecondsTime(response, r => r.TotalDuration, "total_duration", ref responseProps); + OllamaUtilities.TransferNanosecondsTime(response, r => r.LoadDuration, "load_duration", ref responseProps); + + UsageDetails? usage = null; + if (response.PromptEvalCount is int tokens) + { + usage = new() + { + InputTokenCount = tokens, + TotalTokenCount = tokens, + }; + } + + return new(response.Embeddings.Select(e => + new Embedding(e) + { + CreatedAt = DateTimeOffset.UtcNow, + ModelId = response.Model ?? requestModel, + })) + { + Usage = usage, + AdditionalProperties = responseProps, + }; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs new file mode 100644 index 00000000000..07e3530b8ed --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs @@ -0,0 +1,13 @@ +// 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 new file mode 100644 index 00000000000..c4fd2cde87c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs @@ -0,0 +1,21 @@ +// 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 new file mode 100644 index 00000000000..f518413586a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs @@ -0,0 +1,13 @@ +// 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 new file mode 100644 index 00000000000..ba3eab607b8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +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 new file mode 100644 index 00000000000..880e37bec2a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +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 new file mode 100644 index 00000000000..c94d41bd3f3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +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 new file mode 100644 index 00000000000..77ba2a5561c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs @@ -0,0 +1,13 @@ +// 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 new file mode 100644 index 00000000000..1e01d4d5d62 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs @@ -0,0 +1,14 @@ +// 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 required IList Required { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs new file mode 100644 index 00000000000..cc8b548c1a1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +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 new file mode 100644 index 00000000000..457793dc476 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.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.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 new file mode 100644 index 00000000000..a00d0e0e290 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs @@ -0,0 +1,9 @@ +// 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 new file mode 100644 index 00000000000..ba823cde7f8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Net.Http; +using System.Threading; + +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 + { + const double NanosecondsPerMillisecond = 1_000_000; + (metadata ??= [])[key] = TimeSpan.FromMilliseconds(duration / NanosecondsPerMillisecond); + } + catch (OverflowException) + { + // Ignore options that don't convert + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md new file mode 100644 index 00000000000..ef8c60ff7b2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md @@ -0,0 +1,285 @@ +# Microsoft.Extensions.AI.Ollama + +Provides an implementation of the `IChatClient` interface for Ollama. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.AI.Ollama +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +### Chat + +```csharp +using Microsoft.Extensions.AI; + +IChatClient client = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); + +Console.WriteLine(await client.CompleteAsync("What is AI?")); +``` + +### Chat + Conversation History + +```csharp +using Microsoft.Extensions.AI; + +IChatClient client = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); + +Console.WriteLine(await client.CompleteAsync( +[ + new ChatMessage(ChatRole.System, "You are a helpful AI assistant"), + new ChatMessage(ChatRole.User, "What is AI?"), +])); +``` + +### Chat Streaming + +```csharp +using Microsoft.Extensions.AI; + +IChatClient client = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); + +await foreach (var update in client.CompleteStreamingAsync("What is AI?")) +{ + Console.Write(update); +} +``` + +### Tool Calling + +Known limitations: + +- Only a subset of models provided by Ollama support tool calling. +- Tool calling is currently not supported with streaming requests. + +```csharp +using System.ComponentModel; +using Microsoft.Extensions.AI; + +IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); + +IChatClient client = new ChatClientBuilder() + .UseFunctionInvocation() + .Use(ollamaClient); + +ChatOptions chatOptions = new() +{ + Tools = [AIFunctionFactory.Create(GetWeather)] +}; + +Console.WriteLine(await client.CompleteAsync("Do I need an umbrella?", chatOptions)); + +[Description("Gets the weather")] +static string GetWeather() => Random.Shared.NextDouble() > 0.5 ? "It's sunny" : "It's raining"; +``` + +### Caching + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + +IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); + +IChatClient client = new ChatClientBuilder() + .UseDistributedCache(cache) + .Use(ollamaClient); + +for (int i = 0; i < 3; i++) +{ + await foreach (var message in client.CompleteStreamingAsync("In less than 100 words, what is AI?")) + { + Console.Write(message); + } + + Console.WriteLine(); + Console.WriteLine(); +} +``` + +### Telemetry + +```csharp +using Microsoft.Extensions.AI; +using OpenTelemetry.Trace; + +// Configure OpenTelemetry exporter +var sourceName = Guid.NewGuid().ToString(); +var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddConsoleExporter() + .Build(); + +IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); + +IChatClient client = new ChatClientBuilder() + .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) + .Use(ollamaClient); + +Console.WriteLine(await client.CompleteAsync("What is AI?")); +``` + +### Telemetry, Caching, and Tool Calling + +```csharp +using System.ComponentModel; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using OpenTelemetry.Trace; + +// Configure telemetry +var sourceName = Guid.NewGuid().ToString(); +var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddConsoleExporter() + .Build(); + +// Configure caching +IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + +// Configure tool calling +var chatOptions = new ChatOptions +{ + Tools = [AIFunctionFactory.Create(GetPersonAge)] +}; + +IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); + +IChatClient client = new ChatClientBuilder() + .UseDistributedCache(cache) + .UseFunctionInvocation() + .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) + .Use(ollamaClient); + +for (int i = 0; i < 3; i++) +{ + Console.WriteLine(await client.CompleteAsync("How much older is Alice than Bob?", chatOptions)); +} + +[Description("Gets the age of a person specified by name.")] +static int GetPersonAge(string personName) => + personName switch + { + "Alice" => 42, + "Bob" => 35, + _ => 26, + }; +``` + +### Text embedding generation + +```csharp +using Microsoft.Extensions.AI; + +IEmbeddingGenerator> generator = + new OllamaEmbeddingGenerator(new Uri("http://localhost:11434/"), "all-minilm"); + +var embeddings = await generator.GenerateAsync("What is AI?"); + +Console.WriteLine(string.Join(", ", embeddings[0].Vector.ToArray())); +``` + +### Text embedding generation with caching + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + +IEmbeddingGenerator> ollamaGenerator = + new OllamaEmbeddingGenerator(new Uri("http://localhost:11434/"), "all-minilm"); + +IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>() + .UseDistributedCache(cache) + .Use(ollamaGenerator); + +foreach (var prompt in new[] { "What is AI?", "What is .NET?", "What is AI?" }) +{ + var embeddings = await generator.GenerateAsync(prompt); + + Console.WriteLine(string.Join(", ", embeddings[0].Vector.ToArray())); +} +``` + +### Dependency Injection + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +// App Setup +var builder = Host.CreateApplicationBuilder(); +builder.Services.AddSingleton(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))); +builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); + +builder.Services.AddChatClient(b => b + .UseDistributedCache() + .UseLogging() + .Use(new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"))); + +var app = builder.Build(); + +// Elsewhere in the app +var chatClient = app.Services.GetRequiredService(); +Console.WriteLine(await chatClient.CompleteAsync("What is AI?")); +``` + +### Minimal Web API + +```csharp +using Microsoft.Extensions.AI; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddChatClient(c => + c.Use(new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"))); + +builder.Services.AddEmbeddingGenerator>(g => + g.Use(new OllamaEmbeddingGenerator(endpoint, "all-minilm"))); + +var app = builder.Build(); + +app.MapPost("/chat", async (IChatClient client, string message) => +{ + var response = await client.CompleteAsync(message, cancellationToken: default); + return response.Message; +}); + +app.MapPost("/embedding", async (IEmbeddingGenerator> client, string message) => +{ + var response = await client.GenerateAsync(message); + return response[0].Vector; +}); + +app.Run(); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). 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 new file mode 100644 index 00000000000..1efedb13f11 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj @@ -0,0 +1,43 @@ + + + + Microsoft.Extensions.AI + Implementation of generative AI abstractions for OpenAI-compatible endpoints. + AI + + + + preview + 0 + 0 + + + + $(TargetFrameworks);netstandard2.0 + $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002 + true + + + + true + true + true + + + + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs new file mode 100644 index 00000000000..f92fcfa3bc9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -0,0 +1,659 @@ +// 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.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Collections; +using Microsoft.Shared.Diagnostics; +using OpenAI; +using OpenAI.Chat; + +#pragma warning disable S1135 // Track uses of "TODO" tags +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace Microsoft.Extensions.AI; + +/// An for an OpenAI or . +public sealed partial class OpenAIChatClient : IChatClient +{ + /// Default OpenAI endpoint. + private static readonly Uri _defaultOpenAIEndpoint = new("https://api.openai.com/v1"); + + /// The underlying . + private readonly OpenAIClient? _openAIClient; + + /// The underlying . + private readonly ChatClient _chatClient; + + /// Initializes a new instance of the class for the specified . + /// The underlying client. + /// The model to use. + public OpenAIChatClient(OpenAIClient openAIClient, string modelId) + { + _ = Throw.IfNull(openAIClient); + _ = Throw.IfNullOrWhitespace(modelId); + + _openAIClient = openAIClient; + _chatClient = openAIClient.GetChatClient(modelId); + + // https://github.com/openai/openai-dotnet/issues/215 + // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages + // 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. + string providerName = openAIClient.GetType().Name.StartsWith("Azure", StringComparison.Ordinal) ? "azureopenai" : "openai"; + Uri providerUrl = typeof(OpenAIClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(openAIClient) as Uri ?? _defaultOpenAIEndpoint; + + Metadata = new(providerName, providerUrl, modelId); + } + + /// Initializes a new instance of the class for the specified . + /// The underlying client. + public OpenAIChatClient(ChatClient chatClient) + { + _ = Throw.IfNull(chatClient); + + _chatClient = chatClient; + + // https://github.com/openai/openai-dotnet/issues/215 + // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages + // 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. + string providerName = chatClient.GetType().Name.StartsWith("Azure", StringComparison.Ordinal) ? "azureopenai" : "openai"; + Uri providerUrl = typeof(ChatClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(chatClient) as Uri ?? _defaultOpenAIEndpoint; + string? model = typeof(ChatClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(chatClient) as string; + + Metadata = new(providerName, providerUrl, model); + } + + /// Gets or sets to use for any serialization activities related to tool call arguments and results. + public JsonSerializerOptions? ToolCallJsonSerializerOptions { get; set; } + + /// + public ChatClientMetadata Metadata { get; } + + /// + public TService? GetService(object? key = null) + where TService : class => + typeof(TService) == typeof(OpenAIClient) ? (TService?)(object?)_openAIClient : + typeof(TService) == typeof(ChatClient) ? (TService)(object)_chatClient : + this as TService; + + /// + public async Task CompleteAsync( + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + // Make the call to OpenAI. + OpenAI.Chat.ChatCompletion response = (await _chatClient.CompleteChatAsync( + ToOpenAIChatMessages(chatMessages), + ToOpenAIOptions(options), + cancellationToken).ConfigureAwait(false)).Value; + + // Create the return message. + ChatMessage returnMessage = new() + { + RawRepresentation = response, + Role = ToChatRole(response.Role), + }; + + // Populate its content from those in the OpenAI response content. + foreach (ChatMessageContentPart contentPart in response.Content) + { + if (ToAIContent(contentPart, response.Model) is AIContent aiContent) + { + returnMessage.Contents.Add(aiContent); + } + } + + // Also manufacture function calling content items from any tool calls in the response. + if (options?.Tools is { Count: > 0 }) + { + foreach (ChatToolCall toolCall in response.ToolCalls) + { + if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) + { + Dictionary? arguments = FunctionCallHelpers.ParseFunctionCallArguments(toolCall.FunctionArguments, out Exception? parsingException); + + returnMessage.Contents.Add(new FunctionCallContent(toolCall.Id, toolCall.FunctionName, arguments) + { + ModelId = response.Model, + Exception = parsingException, + RawRepresentation = toolCall + }); + } + } + } + + // Wrap the content in a ChatCompletion to return. + var completion = new ChatCompletion([returnMessage]) + { + RawRepresentation = response, + CompletionId = response.Id, + CreatedAt = response.CreatedAt, + ModelId = response.Model, + FinishReason = ToFinishReason(response.FinishReason), + }; + + if (response.Usage is ChatTokenUsage tokenUsage) + { + completion.Usage = new() + { + InputTokenCount = tokenUsage.InputTokenCount, + OutputTokenCount = tokenUsage.OutputTokenCount, + TotalTokenCount = tokenUsage.TotalTokenCount, + }; + + if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails details) + { + completion.Usage.AdditionalProperties = new() { [nameof(details.ReasoningTokenCount)] = details.ReasoningTokenCount }; + } + } + + if (response.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) + { + (completion.AdditionalProperties ??= [])[nameof(response.ContentTokenLogProbabilities)] = contentTokenLogProbs; + } + + if (response.Refusal is string refusal) + { + (completion.AdditionalProperties ??= [])[nameof(response.Refusal)] = refusal; + } + + if (response.RefusalTokenLogProbabilities is { Count: > 0 } refusalTokenLogProbs) + { + (completion.AdditionalProperties ??= [])[nameof(response.RefusalTokenLogProbabilities)] = refusalTokenLogProbs; + } + + if (response.SystemFingerprint is string systemFingerprint) + { + (completion.AdditionalProperties ??= [])[nameof(response.SystemFingerprint)] = systemFingerprint; + } + + return completion; + } + + /// + public async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + Dictionary? functionCallInfos = null; + ChatRole? streamedRole = null; + ChatFinishReason? finishReason = null; + StringBuilder? refusal = null; + string? completionId = null; + DateTimeOffset? createdAt = null; + string? modelId = null; + string? fingerprint = null; + + // Process each update as it arrives + await foreach (OpenAI.Chat.StreamingChatCompletionUpdate chatCompletionUpdate in _chatClient.CompleteChatStreamingAsync( + ToOpenAIChatMessages(chatMessages), ToOpenAIOptions(options), cancellationToken).ConfigureAwait(false)) + { + // The role and finish reason may arrive during any update, but once they've arrived, the same value should be the same for all subsequent updates. + streamedRole ??= chatCompletionUpdate.Role is ChatMessageRole role ? ToChatRole(role) : null; + finishReason ??= chatCompletionUpdate.FinishReason is OpenAI.Chat.ChatFinishReason reason ? ToFinishReason(reason) : null; + completionId ??= chatCompletionUpdate.CompletionId; + createdAt ??= chatCompletionUpdate.CreatedAt; + modelId ??= chatCompletionUpdate.Model; + fingerprint ??= chatCompletionUpdate.SystemFingerprint; + + // Create the response content object. + StreamingChatCompletionUpdate completionUpdate = new() + { + CompletionId = chatCompletionUpdate.CompletionId, + CreatedAt = chatCompletionUpdate.CreatedAt, + FinishReason = finishReason, + RawRepresentation = chatCompletionUpdate, + Role = streamedRole, + }; + + // Populate it with any additional metadata from the OpenAI object. + if (chatCompletionUpdate.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) + { + (completionUpdate.AdditionalProperties ??= [])[nameof(chatCompletionUpdate.ContentTokenLogProbabilities)] = contentTokenLogProbs; + } + + if (chatCompletionUpdate.RefusalTokenLogProbabilities is { Count: > 0 } refusalTokenLogProbs) + { + (completionUpdate.AdditionalProperties ??= [])[nameof(chatCompletionUpdate.RefusalTokenLogProbabilities)] = refusalTokenLogProbs; + } + + if (fingerprint is not null) + { + (completionUpdate.AdditionalProperties ??= [])[nameof(chatCompletionUpdate.SystemFingerprint)] = fingerprint; + } + + // Transfer over content update items. + if (chatCompletionUpdate.ContentUpdate is { Count: > 0 }) + { + foreach (ChatMessageContentPart contentPart in chatCompletionUpdate.ContentUpdate) + { + if (ToAIContent(contentPart, modelId) is AIContent aiContent) + { + completionUpdate.Contents.Add(aiContent); + } + } + } + + // Transfer over refusal updates. + if (chatCompletionUpdate.RefusalUpdate is not null) + { + _ = (refusal ??= new()).Append(chatCompletionUpdate.RefusalUpdate); + } + + // Transfer over tool call updates. + if (chatCompletionUpdate.ToolCallUpdates is { Count: > 0 } toolCallUpdates) + { + foreach (StreamingChatToolCallUpdate toolCallUpdate in toolCallUpdates) + { + functionCallInfos ??= []; + if (!functionCallInfos.TryGetValue(toolCallUpdate.Index, out FunctionCallInfo? existing)) + { + functionCallInfos[toolCallUpdate.Index] = existing = new(); + } + + existing.CallId ??= toolCallUpdate.ToolCallId; + existing.Name ??= toolCallUpdate.FunctionName; + if (toolCallUpdate.FunctionArgumentsUpdate is not null) + { + _ = (existing.Arguments ??= new()).Append(toolCallUpdate.FunctionArgumentsUpdate); + } + } + } + + // Transfer over usage updates. + if (chatCompletionUpdate.Usage is ChatTokenUsage tokenUsage) + { + UsageDetails usageDetails = new() + { + InputTokenCount = tokenUsage.InputTokenCount, + OutputTokenCount = tokenUsage.OutputTokenCount, + TotalTokenCount = tokenUsage.TotalTokenCount, + }; + + if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails details) + { + (usageDetails.AdditionalProperties = [])[nameof(tokenUsage.OutputTokenDetails)] = new Dictionary + { + [nameof(details.ReasoningTokenCount)] = details.ReasoningTokenCount, + }; + } + + // TODO: Add support for prompt token details (e.g. cached tokens) once it's exposed in OpenAI library. + + completionUpdate.Contents.Add(new UsageContent(usageDetails) + { + ModelId = modelId + }); + } + + // Now yield the item. + yield return completionUpdate; + } + + // Now that we've received all updates, combine any for function calls into a single item to yield. + if (functionCallInfos is not null) + { + StreamingChatCompletionUpdate completionUpdate = new() + { + CompletionId = completionId, + CreatedAt = createdAt, + FinishReason = finishReason, + Role = streamedRole, + }; + + foreach (var entry in functionCallInfos) + { + FunctionCallInfo fci = entry.Value; + if (!string.IsNullOrWhiteSpace(fci.Name)) + { + var arguments = FunctionCallHelpers.ParseFunctionCallArguments( + fci.Arguments?.ToString() ?? string.Empty, + out Exception? parsingException); + + completionUpdate.Contents.Add(new FunctionCallContent(fci.CallId!, fci.Name!, arguments) + { + ModelId = modelId, + Exception = parsingException + }); + } + } + + // Refusals are about the model not following the schema for tool calls. As such, if we have any refusal, + // add it to this function calling item. + if (refusal is not null) + { + (completionUpdate.AdditionalProperties ??= [])[nameof(ChatMessageContentPart.Refusal)] = refusal.ToString(); + } + + // Propagate additional relevant metadata. + if (fingerprint is not null) + { + (completionUpdate.AdditionalProperties ??= [])[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)] = fingerprint; + } + + yield return completionUpdate; + } + } + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IChatClient interface. + } + + /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. + private sealed class FunctionCallInfo + { + public string? CallId; + public string? Name; + public StringBuilder? Arguments; + } + + /// Converts an OpenAI role to an Extensions role. + private static ChatRole ToChatRole(ChatMessageRole role) => + role switch + { + ChatMessageRole.System => ChatRole.System, + ChatMessageRole.User => ChatRole.User, + ChatMessageRole.Assistant => ChatRole.Assistant, + ChatMessageRole.Tool => ChatRole.Tool, + _ => new ChatRole(role.ToString()), + }; + + /// Converts an OpenAI finish reason to an Extensions finish reason. + private static ChatFinishReason? ToFinishReason(OpenAI.Chat.ChatFinishReason? finishReason) => + finishReason?.ToString() is not string s ? null : + finishReason switch + { + OpenAI.Chat.ChatFinishReason.Stop => ChatFinishReason.Stop, + OpenAI.Chat.ChatFinishReason.Length => ChatFinishReason.Length, + OpenAI.Chat.ChatFinishReason.ContentFilter => ChatFinishReason.ContentFilter, + OpenAI.Chat.ChatFinishReason.ToolCalls or OpenAI.Chat.ChatFinishReason.FunctionCall => ChatFinishReason.ToolCalls, + _ => new ChatFinishReason(s), + }; + + /// Converts an extensions options instance to an OpenAI options instance. + private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) + { + ChatCompletionOptions result = new(); + + if (options is not null) + { + result.FrequencyPenalty = options.FrequencyPenalty; + result.MaxOutputTokenCount = options.MaxOutputTokens; + result.TopP = options.TopP; + result.PresencePenalty = options.PresencePenalty; + result.Temperature = options.Temperature; + + if (options.StopSequences is { Count: > 0 } stopSequences) + { + foreach (string stopSequence in stopSequences) + { + result.StopSequences.Add(stopSequence); + } + } + + if (options.AdditionalProperties is { Count: > 0 } additionalProperties) + { + if (additionalProperties.TryGetConvertedValue(nameof(result.EndUserId), out string? endUserId)) + { + result.EndUserId = endUserId; + } + + if (additionalProperties.TryGetConvertedValue(nameof(result.IncludeLogProbabilities), out bool includeLogProbabilities)) + { + result.IncludeLogProbabilities = includeLogProbabilities; + } + + if (additionalProperties.TryGetConvertedValue(nameof(result.LogitBiases), out IDictionary? logitBiases)) + { + foreach (KeyValuePair kvp in logitBiases!) + { + result.LogitBiases[kvp.Key] = kvp.Value; + } + } + + if (additionalProperties.TryGetConvertedValue(nameof(result.AllowParallelToolCalls), out bool allowParallelToolCalls)) + { + result.AllowParallelToolCalls = allowParallelToolCalls; + } + +#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. + if (additionalProperties.TryGetConvertedValue(nameof(result.Seed), out long seed)) + { + result.Seed = seed; + } +#pragma warning restore OPENAI001 + + if (additionalProperties.TryGetConvertedValue(nameof(result.TopLogProbabilityCount), out int topLogProbabilityCountInt)) + { + result.TopLogProbabilityCount = topLogProbabilityCountInt; + } + } + + if (options.Tools is { Count: > 0 } tools) + { + foreach (AITool tool in tools) + { + if (tool is AIFunction af) + { + result.Tools.Add(ToOpenAIChatTool(af)); + } + } + + switch (options.ToolMode) + { + case AutoChatToolMode: + result.ToolChoice = ChatToolChoice.CreateAutoChoice(); + break; + + case RequiredChatToolMode required: + result.ToolChoice = required.RequiredFunctionName is null ? + ChatToolChoice.CreateRequiredChoice() : + ChatToolChoice.CreateFunctionChoice(required.RequiredFunctionName); + break; + } + } + + if (options.ResponseFormat is ChatResponseFormatText) + { + result.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); + } + else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) + { + result.ResponseFormat = jsonFormat.Schema is string jsonSchema ? + OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat(jsonFormat.SchemaName ?? "json_schema", BinaryData.FromString(jsonSchema), jsonFormat.SchemaDescription) : + OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); + } + } + + return result; + } + + /// Converts an Extensions function to an OpenAI chat tool. + private ChatTool ToOpenAIChatTool(AIFunction aiFunction) + { + _ = aiFunction.Metadata.AdditionalProperties.TryGetConvertedValue("Strict", out bool strict); + + BinaryData resultParameters = OpenAIChatToolJson.ZeroFunctionParametersSchema; + + var parameters = aiFunction.Metadata.Parameters; + if (parameters is { Count: > 0 }) + { + OpenAIChatToolJson tool = new(); + + foreach (AIFunctionParameterMetadata parameter in parameters) + { + tool.Properties.Add( + parameter.Name, + FunctionCallHelpers.InferParameterJsonSchema(parameter, aiFunction.Metadata, ToolCallJsonSerializerOptions)); + + if (parameter.IsRequired) + { + tool.Required.Add(parameter.Name); + } + } + + resultParameters = BinaryData.FromBytes( + JsonSerializer.SerializeToUtf8Bytes(tool, JsonContext.Default.OpenAIChatToolJson)); + } + + return ChatTool.CreateFunctionTool(aiFunction.Metadata.Name, aiFunction.Metadata.Description, resultParameters, strict); + } + + /// Used to create the JSON payload for an OpenAI chat tool description. + private sealed class OpenAIChatToolJson + { + /// Gets a singleton JSON data for empty parameters. Optimization for the reasonably common case of a parameterless function. + public static BinaryData ZeroFunctionParametersSchema { get; } = new("""{"type":"object","required":[],"properties":{}}"""u8.ToArray()); + + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public List Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + } + + /// Creates an from a . + /// The content part to convert into a content. + /// The model ID. + /// The constructed , or null if the content part could not be converted. + private static AIContent? ToAIContent(ChatMessageContentPart contentPart, string? modelId) + { + AIContent? aiContent = null; + + AdditionalPropertiesDictionary? additionalProperties = null; + + if (contentPart.Kind == ChatMessageContentPartKind.Text) + { + aiContent = new TextContent(contentPart.Text); + } + else if (contentPart.Kind == ChatMessageContentPartKind.Image) + { + ImageContent? imageContent; + aiContent = imageContent = + contentPart.ImageUri is not null ? new ImageContent(contentPart.ImageUri, contentPart.ImageBytesMediaType) : + contentPart.ImageBytes is not null ? new ImageContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : + null; + + if (imageContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) + { + (additionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; + } + } + + if (aiContent is not null) + { + if (contentPart.Refusal is string refusal) + { + (additionalProperties ??= [])[nameof(contentPart.Refusal)] = refusal; + } + + aiContent.ModelId = modelId; + aiContent.AdditionalProperties = additionalProperties; + aiContent.RawRepresentation = contentPart; + } + + return aiContent; + } + + /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. + private IEnumerable ToOpenAIChatMessages(IEnumerable inputs) + { + // Maps all of the M.E.AI types to the corresponding OpenAI types. + // Unrecognized content is ignored. + + foreach (ChatMessage input in inputs) + { + if (input.Role == ChatRole.System) + { + yield return new SystemChatMessage(input.Text) { ParticipantName = input.AuthorName }; + } + else if (input.Role == ChatRole.Tool) + { + foreach (AIContent item in input.Contents) + { + if (item is FunctionResultContent resultContent) + { + string? result = resultContent.Result as string; + if (result is null && resultContent.Result is not null) + { + try + { + result = FunctionCallHelpers.FormatFunctionResultAsJson(resultContent.Result, ToolCallJsonSerializerOptions); + } + catch (NotSupportedException) + { + // If the type can't be serialized, skip it. + } + } + + yield return new ToolChatMessage(resultContent.CallId, result ?? string.Empty); + } + } + } + else if (input.Role == ChatRole.User) + { + yield return new UserChatMessage(input.Contents.Select(static (AIContent item) => item switch + { + TextContent textContent => ChatMessageContentPart.CreateTextPart(textContent.Text), + ImageContent imageContent => imageContent.Data is { IsEmpty: false } data ? ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(data), imageContent.MediaType) : + imageContent.Uri is string uri ? ChatMessageContentPart.CreateImagePart(new Uri(uri)) : + null, + _ => null, + }).Where(c => c is not null)) + { ParticipantName = input.AuthorName }; + } + else if (input.Role == ChatRole.Assistant) + { + Dictionary? toolCalls = null; + + foreach (var content in input.Contents) + { + if (content is FunctionCallContent callRequest && callRequest.CallId is not null && toolCalls?.ContainsKey(callRequest.CallId) is not true) + { + (toolCalls ??= []).Add( + callRequest.CallId, + ChatToolCall.CreateFunctionToolCall( + callRequest.CallId, + callRequest.Name, + BinaryData.FromObjectAsJson(callRequest.Arguments, ToolCallJsonSerializerOptions))); + } + } + + AssistantChatMessage message = toolCalls is not null ? + new(toolCalls.Values) { ParticipantName = input.AuthorName } : + new(input.Text) { ParticipantName = input.AuthorName }; + + if (input.AdditionalProperties?.TryGetConvertedValue(nameof(message.Refusal), out string? refusal) is true) + { + message.Refusal = refusal; + } + + yield return message; + } + } + } + + /// Source-generated JSON type information. + [JsonSerializable(typeof(OpenAIChatToolJson))] + private sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs new file mode 100644 index 00000000000..a33fd34e1ea --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.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 OpenAI; +using OpenAI.Chat; +using OpenAI.Embeddings; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for working with s. +public static class OpenAIClientExtensions +{ + /// Gets an for use with this . + /// The client. + /// The model. + /// An that may be used to converse via the . + public static IChatClient AsChatClient(this OpenAIClient openAIClient, string modelId) => + new OpenAIChatClient(openAIClient, modelId); + + /// Gets an for use with this . + /// The client. + /// An that may be used to converse via the . + public static IChatClient AsChatClient(this ChatClient chatClient) => + new OpenAIChatClient(chatClient); + + /// Gets an for use with this . + /// The client. + /// The model to use. + /// The number of dimensions to generate in each embedding. + /// An that may be used to generate embeddings via the . + public static IEmbeddingGenerator> AsEmbeddingGenerator(this OpenAIClient openAIClient, string modelId, int? dimensions = null) => + new OpenAIEmbeddingGenerator(openAIClient, modelId, dimensions); + + /// Gets an for use with this . + /// The client. + /// The number of dimensions to generate in each embedding. + /// An that may be used to generate embeddings via the . + public static IEmbeddingGenerator> AsEmbeddingGenerator(this EmbeddingClient embeddingClient, int? dimensions = null) => + new OpenAIEmbeddingGenerator(embeddingClient, dimensions); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs new file mode 100644 index 00000000000..e91394befdd --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -0,0 +1,160 @@ +// 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.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Collections; +using Microsoft.Shared.Diagnostics; +using OpenAI; +using OpenAI.Embeddings; + +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace Microsoft.Extensions.AI; + +/// An for an OpenAI . +public sealed class OpenAIEmbeddingGenerator : IEmbeddingGenerator> +{ + /// Default OpenAI endpoint. + private const string DefaultOpenAIEndpoint = "https://api.openai.com/v1"; + + /// The underlying . + private readonly OpenAIClient? _openAIClient; + + /// The underlying . + private readonly EmbeddingClient _embeddingClient; + + /// The number of dimensions produced by the generator. + private readonly int? _dimensions; + + /// Initializes a new instance of the class. + /// The underlying client. + /// The model to use. + /// The number of dimensions to generate in each embedding. + public OpenAIEmbeddingGenerator( + OpenAIClient openAIClient, string modelId, int? dimensions = null) + { + _ = Throw.IfNull(openAIClient); + _ = Throw.IfNullOrWhitespace(modelId); + if (dimensions is < 1) + { + Throw.ArgumentOutOfRangeException(nameof(dimensions), "Value must be greater than 0."); + } + + _openAIClient = openAIClient; + _embeddingClient = openAIClient.GetEmbeddingClient(modelId); + _dimensions = dimensions; + + // https://github.com/openai/openai-dotnet/issues/215 + // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages + // 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. + string providerName = openAIClient.GetType().Name.StartsWith("Azure", StringComparison.Ordinal) ? "azureopenai" : "openai"; + string providerUrl = (typeof(OpenAIClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(openAIClient) as Uri)?.ToString() ?? + DefaultOpenAIEndpoint; + + Metadata = CreateMetadata(dimensions, providerName, providerUrl, modelId); + } + + /// Initializes a new instance of the class. + /// The underlying client. + /// The number of dimensions to generate in each embedding. + public OpenAIEmbeddingGenerator(EmbeddingClient embeddingClient, int? dimensions = null) + { + _ = Throw.IfNull(embeddingClient); + if (dimensions < 1) + { + Throw.ArgumentOutOfRangeException(nameof(dimensions), "Value must be greater than 0."); + } + + _embeddingClient = embeddingClient; + _dimensions = dimensions; + + // https://github.com/openai/openai-dotnet/issues/215 + // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages + // 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. + string providerName = embeddingClient.GetType().Name.StartsWith("Azure", StringComparison.Ordinal) ? "azureopenai" : "openai"; + string providerUrl = (typeof(EmbeddingClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(embeddingClient) as Uri)?.ToString() ?? + DefaultOpenAIEndpoint; + + FieldInfo? modelField = typeof(EmbeddingClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + string? model = modelField?.GetValue(embeddingClient) as string; + + Metadata = CreateMetadata(dimensions, providerName, providerUrl, model); + } + + /// Creates the for this instance. + private static EmbeddingGeneratorMetadata CreateMetadata(int? dimensions, string providerName, string providerUrl, string? model) => + new(providerName, Uri.TryCreate(providerUrl, UriKind.Absolute, out Uri? providerUri) ? providerUri : null, model, dimensions); + + /// + public EmbeddingGeneratorMetadata Metadata { get; } + + /// + public TService? GetService(object? key = null) + where TService : class + => + typeof(TService) == typeof(OpenAIClient) ? (TService?)(object?)_openAIClient : + typeof(TService) == typeof(EmbeddingClient) ? (TService)(object)_embeddingClient : + this as TService; + + /// + public async Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + OpenAI.Embeddings.EmbeddingGenerationOptions? openAIOptions = ToOpenAIOptions(options); + + var embeddings = (await _embeddingClient.GenerateEmbeddingsAsync(values, openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + + return new(embeddings.Select(e => + new Embedding(e.ToFloats()) + { + CreatedAt = DateTimeOffset.UtcNow, + ModelId = embeddings.Model, + })) + { + Usage = new() + { + InputTokenCount = embeddings.Usage.InputTokenCount, + TotalTokenCount = embeddings.Usage.TotalTokenCount + }, + }; + } + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IEmbeddingGenerator interface. + } + + /// Converts an extensions options instance to an OpenAI options instance. + private OpenAI.Embeddings.EmbeddingGenerationOptions? ToOpenAIOptions(EmbeddingGenerationOptions? options) + { + OpenAI.Embeddings.EmbeddingGenerationOptions openAIOptions = new() + { + Dimensions = _dimensions, + }; + + if (options?.AdditionalProperties is { Count: > 0 } additionalProperties) + { + // Allow per-instance dimensions to be overridden by a per-call property + if (additionalProperties.TryGetConvertedValue(nameof(openAIOptions.Dimensions), out int? dimensions)) + { + openAIOptions.Dimensions = dimensions; + } + + if (additionalProperties.TryGetConvertedValue(nameof(openAIOptions.EndUserId), out string? endUserId)) + { + openAIOptions.EndUserId = endUserId; + } + } + + return openAIOptions; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md new file mode 100644 index 00000000000..f7af212f4d7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md @@ -0,0 +1,313 @@ +# Microsoft.Extensions.AI.OpenAI + +Provides an implementation of the `IChatClient` interface for the `OpenAI` package and OpenAI-compatible endpoints. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.AI.OpenAI +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +### Chat + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; + +IChatClient client = + new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsChatClient("gpt-4o-mini"); + +Console.WriteLine(await client.CompleteAsync("What is AI?")); +``` + +### Chat + Conversation History + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; + +IChatClient client = + new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsChatClient("gpt-4o-mini"); + +Console.WriteLine(await client.CompleteAsync( +[ + new ChatMessage(ChatRole.System, "You are a helpful AI assistant"), + new ChatMessage(ChatRole.User, "What is AI?"), +])); +``` + +### Chat streaming + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; + +IChatClient client = + new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsChatClient("gpt-4o-mini"); + +await foreach (var update in client.CompleteStreamingAsync("What is AI?")) +{ + Console.Write(update); +} +``` + +### Tool calling + +```csharp +using System.ComponentModel; +using Microsoft.Extensions.AI; +using OpenAI; + +IChatClient openaiClient = + new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsChatClient("gpt-4o-mini"); + +IChatClient client = new ChatClientBuilder() + .UseFunctionInvocation() + .Use(openaiClient); + +ChatOptions chatOptions = new() +{ + Tools = [AIFunctionFactory.Create(GetWeather)] +}; + +await foreach (var message in client.CompleteStreamingAsync("Do I need an umbrella?", chatOptions)) +{ + Console.Write(message); +} + +[Description("Gets the weather")] +static string GetWeather() => Random.Shared.NextDouble() > 0.5 ? "It's sunny" : "It's raining"; +``` + +### Caching + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using OpenAI; + +IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + +IChatClient openaiClient = + new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsChatClient("gpt-4o-mini"); + +IChatClient client = new ChatClientBuilder() + .UseDistributedCache(cache) + .Use(openaiClient); + +for (int i = 0; i < 3; i++) +{ + await foreach (var message in client.CompleteStreamingAsync("In less than 100 words, what is AI?")) + { + Console.Write(message); + } + + Console.WriteLine(); + Console.WriteLine(); +} +``` + +### Telemetry + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; +using OpenTelemetry.Trace; + +// Configure OpenTelemetry exporter +var sourceName = Guid.NewGuid().ToString(); +var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddConsoleExporter() + .Build(); + +IChatClient openaiClient = + new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsChatClient("gpt-4o-mini"); + +IChatClient client = new ChatClientBuilder() + .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) + .Use(openaiClient); + +Console.WriteLine(await client.CompleteAsync("What is AI?")); +``` + +### Telemetry, Caching, and Tool Calling + +```csharp +using System.ComponentModel; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using OpenAI; +using OpenTelemetry.Trace; + +// Configure telemetry +var sourceName = Guid.NewGuid().ToString(); +var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddConsoleExporter() + .Build(); + +// Configure caching +IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + +// Configure tool calling +var chatOptions = new ChatOptions +{ + Tools = [AIFunctionFactory.Create(GetPersonAge)] +}; + +IChatClient openaiClient = + new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsChatClient("gpt-4o-mini"); + +IChatClient client = new ChatClientBuilder() + .UseDistributedCache(cache) + .UseFunctionInvocation() + .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) + .Use(openaiClient); + +for (int i = 0; i < 3; i++) +{ + Console.WriteLine(await client.CompleteAsync("How much older is Alice than Bob?", chatOptions)); +} + +[Description("Gets the age of a person specified by name.")] +static int GetPersonAge(string personName) => + personName switch + { + "Alice" => 42, + "Bob" => 35, + _ => 26, + }; +``` + +### Text embedding generation + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; + +IEmbeddingGenerator> generator = + new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsEmbeddingGenerator("text-embedding-3-small"); + +var embeddings = await generator.GenerateAsync("What is AI?"); + +Console.WriteLine(string.Join(", ", embeddings[0].Vector.ToArray())); +``` + +### Text embedding generation with caching + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using OpenAI; + +IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + +IEmbeddingGenerator> openAIGenerator = + new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) + .AsEmbeddingGenerator("text-embedding-3-small"); + +IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>() + .UseDistributedCache(cache) + .Use(openAIGenerator); + +foreach (var prompt in new[] { "What is AI?", "What is .NET?", "What is AI?" }) +{ + var embeddings = await generator.GenerateAsync(prompt); + + Console.WriteLine(string.Join(", ", embeddings[0].Vector.ToArray())); +} +``` + +### Dependency Injection + +```csharp +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenAI; + +// App Setup +var builder = Host.CreateApplicationBuilder(); +builder.Services.AddSingleton(new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY"))); +builder.Services.AddSingleton(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))); +builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); + +builder.Services.AddChatClient(b => b + .UseDistributedCache() + .UseLogging() + .Use(b.Services.GetRequiredService().AsChatClient("gpt-4o-mini"))); + +var app = builder.Build(); + +// Elsewhere in the app +var chatClient = app.Services.GetRequiredService(); +Console.WriteLine(await chatClient.CompleteAsync("What is AI?")); +``` + +### Minimal Web API + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(new OpenAIClient(builder.Configuration["OPENAI_API_KEY"])); + +builder.Services.AddChatClient(b => + b.Use(b.Services.GetRequiredService().AsChatClient("gpt-4o-mini"))); + +builder.Services.AddEmbeddingGenerator>(g => + g.Use(g.Services.GetRequiredService().AsEmbeddingGenerator("text-embedding-3-small"))); + +var app = builder.Build(); + +app.MapPost("/chat", async (IChatClient client, string message) => +{ + var response = await client.CompleteAsync(message); + return response.Message; +}); + +app.MapPost("/embedding", async (IEmbeddingGenerator> client, string message) => +{ + var response = await client.GenerateAsync(message); + return response[0].Vector; +}); + +app.Run(); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs b/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs new file mode 100644 index 00000000000..8128926f942 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides internal helpers for implementing caching services. +internal static class CachingHelpers +{ + /// Computes a default cache key for the specified parameters. + /// Specifies the type of the data being used to compute the key. + /// The data with which to compute the key. + /// The . + /// A string that will be used as a cache key. + public static string GetCacheKey(TValue value, JsonSerializerOptions serializerOptions) + => GetCacheKey(value, false, serializerOptions); + + /// Computes a default cache key for the specified parameters. + /// Specifies the type of the data being used to compute the key. + /// The data with which to compute the key. + /// Another data item that causes the key to vary. + /// The . + /// A string that will be used as a cache key. + public static string GetCacheKey(TValue value, bool flag, JsonSerializerOptions serializerOptions) + { + _ = Throw.IfNull(value); + _ = Throw.IfNull(serializerOptions); + serializerOptions.MakeReadOnly(); + + var jsonKeyBytes = JsonSerializer.SerializeToUtf8Bytes(value, serializerOptions.GetTypeInfo(typeof(TValue))); + + if (flag && jsonKeyBytes.Length > 0) + { + // Make an arbitrary change to the hash input based on the flag + // The alternative would be including the flag in "value" in the + // first place, but that's likely to require an extra allocation + // or the inclusion of another type in the JsonSerializerContext. + // This is a micro-optimization we can change at any time. + jsonKeyBytes[0] = (byte)(byte.MaxValue - jsonKeyBytes[0]); + } + + // The complete JSON representation is excessively long for a cache key, duplicating much of the content + // from the value. So we use a hash of it as the default key. +#if NET8_0_OR_GREATER + Span hashData = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(jsonKeyBytes, hashData); + return Convert.ToHexString(hashData); +#else + using var sha256 = SHA256.Create(); + var hashData = sha256.ComputeHash(jsonKeyBytes); + return BitConverter.ToString(hashData).Replace("-", string.Empty); +#endif + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs new file mode 100644 index 00000000000..89a778cdd1b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs @@ -0,0 +1,155 @@ +// 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.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating chat client that caches the results of chat calls. +/// +public abstract class CachingChatClient : DelegatingChatClient +{ + /// Initializes a new instance of the class. + /// The underlying . + protected CachingChatClient(IChatClient innerClient) + : base(innerClient) + { + } + + /// + public override async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + // We're only storing the final result, not the in-flight task, so that we can avoid caching failures + // or having problems when one of the callers cancels but others don't. This has the drawback that + // concurrent callers might trigger duplicate requests, but that's acceptable. + var cacheKey = GetCacheKey(false, chatMessages, options); + + if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is ChatCompletion existing) + { + return existing; + } + + var result = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + await WriteCacheAsync(cacheKey, result, cancellationToken).ConfigureAwait(false); + return result; + } + + /// + public override async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + var cacheKey = GetCacheKey(true, chatMessages, options); + if (await ReadCacheStreamingAsync(cacheKey, cancellationToken).ConfigureAwait(false) is { } existingChunks) + { + foreach (var chunk in existingChunks) + { + yield return chunk; + } + } + else + { + var capturedItems = new List(); + StreamingChatCompletionUpdate? previousCoalescedCopy = null; + await foreach (var item in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) + { + yield return item; + + // If this item is compatible with the previous one, we will coalesce them in the cache + var previous = capturedItems.Count > 0 ? capturedItems[capturedItems.Count - 1] : null; + if (item.ChoiceIndex == 0 + && item.Contents.Count == 1 + && item.Contents[0] is TextContent currentTextContent + && previous is { ChoiceIndex: 0 } + && previous.Role == item.Role + && previous.Contents is { Count: 1 } + && previous.Contents[0] is TextContent previousTextContent) + { + if (!ReferenceEquals(previous, previousCoalescedCopy)) + { + // We don't want to mutate any object that we also yield, since the recipient might + // not expect that. Instead make a copy we can safely mutate. + previousCoalescedCopy = new() + { + Role = previous.Role, + AuthorName = previous.AuthorName, + AdditionalProperties = previous.AdditionalProperties, + ChoiceIndex = previous.ChoiceIndex, + RawRepresentation = previous.RawRepresentation, + Contents = [new TextContent(previousTextContent.Text)] + }; + + // The last item we captured was before we knew it could be coalesced + // with this one, so replace it with the coalesced copy + capturedItems[capturedItems.Count - 1] = previousCoalescedCopy; + } + +#pragma warning disable S1643 // Strings should not be concatenated using '+' in a loop + ((TextContent)previousCoalescedCopy.Contents[0]).Text += currentTextContent.Text; +#pragma warning restore S1643 + } + else + { + capturedItems.Add(item); + } + } + + await WriteCacheStreamingAsync(cacheKey, capturedItems, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Computes a cache key for the specified call parameters. + /// + /// A flag to indicate if this is a streaming call. + /// The chat content. + /// The chat options to configure the request. + /// A string that will be used as a cache key. + protected abstract string GetCacheKey(bool streaming, IList chatMessages, ChatOptions? options); + + /// + /// Returns a previously cached , if available. + /// This is used when there is a call to . + /// + /// The cache key. + /// The to monitor for cancellation requests. + /// The previously cached data, if available, otherwise . + protected abstract Task ReadCacheAsync(string key, CancellationToken cancellationToken); + + /// + /// Returns a previously cached list of values, if available. + /// This is used when there is a call to . + /// + /// The cache key. + /// The to monitor for cancellation requests. + /// The previously cached data, if available, otherwise . + protected abstract Task?> ReadCacheStreamingAsync(string key, CancellationToken cancellationToken); + + /// + /// Stores a in the underlying cache. + /// This is used when there is a call to . + /// + /// The cache key. + /// The to be stored. + /// The to monitor for cancellation requests. + /// A representing the completion of the operation. + protected abstract Task WriteCacheAsync(string key, ChatCompletion value, CancellationToken cancellationToken); + + /// + /// Stores a list of values in the underlying cache. + /// This is used when there is a call to . + /// + /// The cache key. + /// The to be stored. + /// The to monitor for cancellation requests. + /// A representing the completion of the operation. + protected abstract Task WriteCacheStreamingAsync(string key, IReadOnlyList value, CancellationToken cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs new file mode 100644 index 00000000000..d7934ba7809 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A builder for creating pipelines of . +public sealed class ChatClientBuilder +{ + /// The registered client factory instances. + private List>? _clientFactories; + + /// Initializes a new instance of the class. + /// The service provider to use for dependency injection. + public ChatClientBuilder(IServiceProvider? services = null) + { + Services = services ?? EmptyServiceProvider.Instance; + } + + /// Gets the associated with the builder instance. + public IServiceProvider Services { get; } + + /// Completes the pipeline by adding a final that represents the underlying backend. This is typically a client for an LLM service. + /// The inner client to use. + /// An instance of that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. + public IChatClient Use(IChatClient innerClient) + { + var chatClient = Throw.IfNull(innerClient); + + // To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost. + if (_clientFactories is not null) + { + for (var i = _clientFactories.Count - 1; i >= 0; i--) + { + chatClient = _clientFactories[i](Services, chatClient) ?? + throw new InvalidOperationException( + $"The {nameof(ChatClientBuilder)} entry at index {i} returned null. " + + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IChatClient)} instances."); + } + } + + return chatClient; + } + + /// Adds a factory for an intermediate chat client to the chat client pipeline. + /// The client factory function. + /// The updated instance. + public ChatClientBuilder Use(Func clientFactory) + { + _ = Throw.IfNull(clientFactory); + + return Use((_, innerClient) => clientFactory(innerClient)); + } + + /// Adds a factory for an intermediate chat client to the chat client pipeline. + /// The client factory function. + /// The updated instance. + public ChatClientBuilder Use(Func clientFactory) + { + _ = Throw.IfNull(clientFactory); + + (_clientFactories ??= []).Add(clientFactory); + return this; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs new file mode 100644 index 00000000000..246ac7f3689 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for registering with a . +public static class ChatClientBuilderServiceCollectionExtensions +{ + /// Adds a chat client to the . + /// The to which the client should be added. + /// The factory to use to construct the instance. + /// The collection. + /// The client is registered as a scoped service. + public static IServiceCollection AddChatClient( + this IServiceCollection services, + Func clientFactory) + { + _ = Throw.IfNull(services); + _ = Throw.IfNull(clientFactory); + + return services.AddScoped(services => + clientFactory(new ChatClientBuilder(services))); + } + + /// Adds a chat client to the . + /// The to which the client should be added. + /// The key with which to associate the client. + /// The factory to use to construct the instance. + /// The collection. + /// The client is registered as a scoped service. + public static IServiceCollection AddKeyedChatClient( + this IServiceCollection services, + object serviceKey, + Func clientFactory) + { + _ = Throw.IfNull(services); + _ = Throw.IfNull(serviceKey); + _ = Throw.IfNull(clientFactory); + + return services.AddKeyedScoped(serviceKey, (services, _) => + clientFactory(new ChatClientBuilder(services))); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs new file mode 100644 index 00000000000..2a8b794c50e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -0,0 +1,225 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +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 System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods on that simplify working with structured output. +/// +public static partial class ChatClientStructuredOutputExtensions +{ + private const string UsesReflectionJsonSerializerMessage = + "This method uses the reflection-based JsonSerializer which can break in trimmed or AOT applications."; + + private static JsonSerializerOptions? _defaultJsonSerializerOptions; + + /// Sends chat messages to the model, requesting a response matching the type . + /// The . + /// The chat content to send. + /// The chat options to configure the request. + /// + /// Optionally specifies whether to set a JSON schema on the . + /// This improves reliability if the underlying model supports native structured output with a schema, but may cause an error if the model does not support it. + /// If not specified, the underlying provider's default will be used. + /// + /// The to monitor for cancellation requests. The default is . + /// The response messages generated by the client. + /// + /// The returned messages will not have been added to . However, any intermediate messages generated implicitly + /// by the client, including any messages for roundtrips to the model as part of the implementation of this request, will be included. + /// + /// The type of structured output to request. + [RequiresUnreferencedCode(UsesReflectionJsonSerializerMessage)] + [RequiresDynamicCode(UsesReflectionJsonSerializerMessage)] + public static Task> CompleteAsync( + this IChatClient chatClient, + IList chatMessages, + ChatOptions? options = null, + bool? useNativeJsonSchema = null, + CancellationToken cancellationToken = default) + where T : class => + CompleteAsync(chatClient, chatMessages, DefaultJsonSerializerOptions, options, useNativeJsonSchema, cancellationToken); + + /// Sends a user chat text message to the model, requesting a response matching the type . + /// The . + /// The text content for the chat message to send. + /// The chat options to configure the request. + /// + /// Optionally specifies whether to set a JSON schema on the . + /// This improves reliability if the underlying model supports native structured output with a schema, but may cause an error if the model does not support it. + /// If not specified, the underlying provider's default will be used. + /// + /// The to monitor for cancellation requests. The default is . + /// The response messages generated by the client. + /// The type of structured output to request. + [RequiresDynamicCode("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. " + + "Use System.Text.Json source generation for native AOT applications.")] + public static Task> CompleteAsync( + this IChatClient chatClient, + string chatMessage, + ChatOptions? options = null, + bool? useNativeJsonSchema = null, + CancellationToken cancellationToken = default) + where T : class => + CompleteAsync(chatClient, [new ChatMessage(ChatRole.User, chatMessage)], options, useNativeJsonSchema, cancellationToken); + + /// Sends a user chat text message to the model, requesting a response matching the type . + /// The . + /// The text content for the chat message to send. + /// The JSON serialization options to use. + /// The chat options to configure the request. + /// + /// Optionally specifies whether to set a JSON schema on the . + /// This improves reliability if the underlying model supports native structured output with a schema, but may cause an error if the model does not support it. + /// If not specified, the underlying provider's default will be used. + /// + /// The to monitor for cancellation requests. The default is . + /// The response messages generated by the client. + /// The type of structured output to request. + public static Task> CompleteAsync( + this IChatClient chatClient, + string chatMessage, + JsonSerializerOptions serializerOptions, + ChatOptions? options = null, + bool? useNativeJsonSchema = null, + CancellationToken cancellationToken = default) + where T : class => + CompleteAsync(chatClient, [new ChatMessage(ChatRole.User, chatMessage)], serializerOptions, options, useNativeJsonSchema, cancellationToken); + + /// Sends chat messages to the model, requesting a response matching the type . + /// The . + /// The chat content to send. + /// The JSON serialization options to use. + /// The chat options to configure the request. + /// + /// Optionally specifies whether to set a JSON schema on the . + /// This improves reliability if the underlying model supports native structured output with a schema, but may cause an error if the model does not support it. + /// If not specified, the underlying provider's default will be used. + /// + /// The to monitor for cancellation requests. The default is . + /// The response messages generated by the client. + /// + /// The returned messages will not have been added to . However, any intermediate messages generated implicitly + /// by the client, including any messages for roundtrips to the model as part of the implementation of this request, will be included. + /// + /// The type of structured output to request. + public static async Task> CompleteAsync( + this IChatClient chatClient, + IList chatMessages, + JsonSerializerOptions serializerOptions, + ChatOptions? options = null, + bool? useNativeJsonSchema = null, + CancellationToken cancellationToken = default) + where T : class + { + _ = Throw.IfNull(chatClient); + _ = Throw.IfNull(chatMessages); + _ = Throw.IfNull(serializerOptions); + + serializerOptions.MakeReadOnly(); + + var schemaNode = (JsonObject)serializerOptions.GetJsonSchemaAsNode(typeof(T), new() + { + TreatNullObliviousAsNonNullable = true, + TransformSchemaNode = static (context, node) => + { + if (node is JsonObject obj) + { + if (obj.TryGetPropertyValue("enum", out _) + && !obj.TryGetPropertyValue("type", out _)) + { + obj.Insert(0, "type", "string"); + } + } + + return node; + }, + }); + schemaNode.Insert(0, "$schema", "https://json-schema.org/draft/2020-12/schema"); + schemaNode.Add("additionalProperties", false); + var schema = JsonSerializer.Serialize(schemaNode, JsonNodeContext.Default.JsonNode); + + ChatMessage? promptAugmentation = null; + options = (options ?? new()).Clone(); + + // Currently there's no way for the inner IChatClient to specify whether structured output + // is supported, so we always default to false. In the future, some mechanism of declaring + // capabilities may be added (e.g., on ChatClientMetadata). + if (useNativeJsonSchema.GetValueOrDefault(false)) + { + // When using native structured output, we don't add any additional prompt, because + // the LLM backend is meant to do whatever's needed to explain the schema to the LLM. + options.ResponseFormat = ChatResponseFormat.ForJsonSchema( + schema, + schemaName: typeof(T).Name, + schemaDescription: typeof(T).GetCustomAttribute()?.Description); + } + else + { + options.ResponseFormat = ChatResponseFormat.Json; + + // When not using native structured output, augment the chat messages with a schema prompt +#pragma warning disable SA1118 // Parameter should not span multiple lines + promptAugmentation = new ChatMessage(ChatRole.System, $$""" + Respond with a JSON value conforming to the following schema: + ``` + {{schema}} + ``` + """); +#pragma warning restore SA1118 // Parameter should not span multiple lines + + chatMessages.Add(promptAugmentation); + } + + try + { + var result = await chatClient.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + return new ChatCompletion(result, serializerOptions); + } + finally + { + if (promptAugmentation is not null) + { + _ = chatMessages.Remove(promptAugmentation); + } + } + } + + private static JsonSerializerOptions DefaultJsonSerializerOptions + { + [RequiresUnreferencedCode(UsesReflectionJsonSerializerMessage)] + [RequiresDynamicCode(UsesReflectionJsonSerializerMessage)] + get => _defaultJsonSerializerOptions ?? GetOrCreateDefaultJsonSerializerOptions(); + } + + [RequiresUnreferencedCode(UsesReflectionJsonSerializerMessage)] + [RequiresDynamicCode(UsesReflectionJsonSerializerMessage)] + private static JsonSerializerOptions GetOrCreateDefaultJsonSerializerOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.General) + { + Converters = { new JsonStringEnumConverter() }, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + WriteIndented = true, + }; + return Interlocked.CompareExchange(ref _defaultJsonSerializerOptions, options, null) ?? options; + } + + [JsonSerializable(typeof(JsonNode))] + [JsonSourceGenerationOptions(WriteIndented = true)] + private sealed partial class JsonNodeContext : JsonSerializerContext; +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs new file mode 100644 index 00000000000..344a01d2c22 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs @@ -0,0 +1,147 @@ +// 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.Buffers; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents the result of a chat completion request with structured output. +/// The type of value expected from the chat completion. +/// +/// Language models are not guaranteed to honor the requested schema. If the model's output is not +/// parseable as the expected type, then will return . +/// You can access the underlying JSON response on the property. +/// +public class ChatCompletion : ChatCompletion +{ + private static readonly JsonReaderOptions _allowMultipleValuesJsonReaderOptions = new JsonReaderOptions { AllowMultipleValues = true }; + private readonly JsonSerializerOptions _serializerOptions; + + private T? _deserializedResult; + private bool _hasDeserializedResult; + + /// Initializes a new instance of the class. + /// The unstructured that is being wrapped. + /// The to use when deserializing the result. + public ChatCompletion(ChatCompletion completion, JsonSerializerOptions serializerOptions) + : base(Throw.IfNull(completion).Choices) + { + _serializerOptions = Throw.IfNull(serializerOptions); + CompletionId = completion.CompletionId; + ModelId = completion.ModelId; + CreatedAt = completion.CreatedAt; + FinishReason = completion.FinishReason; + Usage = completion.Usage; + RawRepresentation = completion.RawRepresentation; + AdditionalProperties = completion.AdditionalProperties; + } + + /// + /// Gets the result of the chat completion as an instance of . + /// If the response did not contain JSON, or if deserialization fails, this property will throw. + /// To avoid exceptions, use instead. + /// + public T Result + { + get + { + var result = GetResultCore(out var failureReason); + return failureReason switch + { + FailureReason.ResultDidNotContainJson => throw new InvalidOperationException("The response did not contain text to be deserialized"), + FailureReason.DeserializationProducedNull => throw new InvalidOperationException("The deserialized response is null"), + _ => result!, + }; + } + } + + /// + /// Attempts to deserialize the result to produce an instance of . + /// + /// The result. + /// if the result was produced, otherwise . + public bool TryGetResult([NotNullWhen(true)] out T? result) + { + try + { + result = GetResultCore(out var failureReason); + return failureReason is null; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + result = default; + return false; + } +#pragma warning restore CA1031 // Do not catch general exception types + } + + private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo typeInfo) + { + // We need to deserialize only the first top-level object as a workaround for a common LLM backend + // issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call. + // See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348 + var utf8ByteLength = Encoding.UTF8.GetByteCount(json); + var buffer = ArrayPool.Shared.Rent(utf8ByteLength); + try + { + var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0); + var utf8Span = new ReadOnlySpan(buffer, 0, utf8SpanLength); + var reader = new Utf8JsonReader(utf8Span, _allowMultipleValuesJsonReaderOptions); + return JsonSerializer.Deserialize(ref reader, typeInfo); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private string? GetResultAsJson() + { + var choice = Choices.Count == 1 ? Choices[0] : null; + var content = choice?.Contents.Count == 1 ? choice.Contents[0] : null; + return (content as TextContent)?.Text; + } + + private T? GetResultCore(out FailureReason? failureReason) + { + if (_hasDeserializedResult) + { + failureReason = default; + return _deserializedResult; + } + + var json = GetResultAsJson(); + if (string.IsNullOrEmpty(json)) + { + failureReason = FailureReason.ResultDidNotContainJson; + return default; + } + + // If there's an exception here, we want it to propagate, since the Result property is meant to throw directly + var deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo)_serializerOptions.GetTypeInfo(typeof(T))); + if (deserialized is null) + { + failureReason = FailureReason.DeserializationProducedNull; + return default; + } + + _deserializedResult = deserialized; + _hasDeserializedResult = true; + failureReason = default; + return deserialized; + } + + private enum FailureReason + { + ResultDidNotContainJson, + DeserializationProducedNull, + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs new file mode 100644 index 00000000000..a8a4b9269e2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable SA1629 // Documentation text should end with a period + +namespace Microsoft.Extensions.AI; + +/// A delegating chat client that updates or replaces the used by the remainder of the pipeline. +/// +/// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options +/// with a new instance, the callback may simply return that new instance, for example _ => new ChatOptions() { MaxTokens = 1000 }. To provide +/// a new instance only if the caller-supplied instance is `null`, the callback may conditionally return a new instance, for example +/// options => options ?? new ChatOptions() { MaxTokens = 1000 }. Any changes to the caller-provided options instance will persist on the +/// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance +/// and mutating the clone, for example: +/// +/// options => +/// { +/// var newOptions = options?.Clone() ?? new(); +/// newOptions.MaxTokens = 1000; +/// return newOptions; +/// } +/// +/// +public sealed class ConfigureOptionsChatClient : DelegatingChatClient +{ + /// The callback delegate used to configure options. + private readonly Func _configureOptions; + + /// Initializes a new instance of the class with the specified callback. + /// The inner client. + /// + /// The delegate to invoke to configure the instance. It is passed the caller-supplied + /// instance and should return the configured instance to use. + /// + public ConfigureOptionsChatClient(IChatClient innerClient, Func configureOptions) + : base(innerClient) + { + _configureOptions = Throw.IfNull(configureOptions); + } + + /// + public override async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return await base.CompleteAsync(chatMessages, _configureOptions(options), cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var update in base.CompleteStreamingAsync(chatMessages, _configureOptions(options), cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..12b903c0dac --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs @@ -0,0 +1,47 @@ +// 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.Shared.Diagnostics; + +#pragma warning disable SA1629 // Documentation text should end with a period + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +public static class ConfigureOptionsChatClientBuilderExtensions +{ + /// + /// Adds a callback that updates or replaces . This can be used to set default options. + /// + /// The . + /// + /// The delegate to invoke to configure the instance. It is passed the caller-supplied + /// instance and should return the configured instance to use. + /// + /// The . + /// + /// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options + /// with a new instance, the callback may simply return that new instance, for example _ => new ChatOptions() { MaxTokens = 1000 }. To provide + /// a new instance only if the caller-supplied instance is `null`, the callback may conditionally return a new instance, for example + /// options => options ?? new ChatOptions() { MaxTokens = 1000 }. Any changes to the caller-provided options instance will persist on the + /// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance + /// and mutating the clone, for example: + /// + /// options => + /// { + /// var newOptions = options?.Clone() ?? new(); + /// newOptions.MaxTokens = 1000; + /// return newOptions; + /// } + /// + /// + public static ChatClientBuilder UseChatOptions( + this ChatClientBuilder builder, Func configureOptions) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(configureOptions); + + return builder.Use(innerClient => new ConfigureOptionsChatClient(innerClient, configureOptions)); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs new file mode 100644 index 00000000000..65c50c090bd --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating chat client that caches the results of completion calls, storing them as JSON in an . +/// +public class DistributedCachingChatClient : CachingChatClient +{ + private readonly IDistributedCache _storage; + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used as the backing store for the cache. + public DistributedCachingChatClient(IChatClient innerClient, IDistributedCache storage) + : base(innerClient) + { + _storage = Throw.IfNull(storage); + _jsonSerializerOptions = JsonDefaults.Options; + } + + /// Gets or sets JSON serialization options to use when serializing cache data. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + protected override async Task ReadCacheAsync(string key, CancellationToken cancellationToken) + { + _ = Throw.IfNull(key); + _jsonSerializerOptions.MakeReadOnly(); + + if (await _storage.GetAsync(key, cancellationToken).ConfigureAwait(false) is byte[] existingJson) + { + return (ChatCompletion?)JsonSerializer.Deserialize(existingJson, _jsonSerializerOptions.GetTypeInfo(typeof(ChatCompletion))); + } + + return null; + } + + /// + protected override async Task?> ReadCacheStreamingAsync(string key, CancellationToken cancellationToken) + { + _ = Throw.IfNull(key); + _jsonSerializerOptions.MakeReadOnly(); + + if (await _storage.GetAsync(key, cancellationToken).ConfigureAwait(false) is byte[] existingJson) + { + return (IReadOnlyList?)JsonSerializer.Deserialize(existingJson, _jsonSerializerOptions.GetTypeInfo(typeof(IReadOnlyList))); + } + + return null; + } + + /// + protected override async Task WriteCacheAsync(string key, ChatCompletion value, CancellationToken cancellationToken) + { + _ = Throw.IfNull(key); + _ = Throw.IfNull(value); + _jsonSerializerOptions.MakeReadOnly(); + + var newJson = JsonSerializer.SerializeToUtf8Bytes(value, _jsonSerializerOptions.GetTypeInfo(typeof(ChatCompletion))); + await _storage.SetAsync(key, newJson, cancellationToken).ConfigureAwait(false); + } + + /// + protected override async Task WriteCacheStreamingAsync(string key, IReadOnlyList value, CancellationToken cancellationToken) + { + _ = Throw.IfNull(key); + _ = Throw.IfNull(value); + _jsonSerializerOptions.MakeReadOnly(); + + var newJson = JsonSerializer.SerializeToUtf8Bytes(value, _jsonSerializerOptions.GetTypeInfo(typeof(IReadOnlyList))); + await _storage.SetAsync(key, newJson, cancellationToken).ConfigureAwait(false); + } + + /// + protected override string GetCacheKey(bool streaming, IList chatMessages, ChatOptions? options) + { + // While it might be desirable to include ChatOptions in the cache key, it's not always possible, + // since ChatOptions can contain types that are not guaranteed to be serializable or have a stable + // hashcode across multiple calls. So the default cache key is simply the JSON representation of + // the chat contents. Developers may subclass and override this to provide custom rules. + _jsonSerializerOptions.MakeReadOnly(); + return CachingHelpers.GetCacheKey(chatMessages, streaming, _jsonSerializerOptions); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..d465161e1e4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClientBuilderExtensions.cs @@ -0,0 +1,36 @@ +// 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.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Extension methods for adding a to an pipeline. +/// +public static class DistributedCachingChatClientBuilderExtensions +{ + /// + /// Adds a as the next stage in the pipeline. + /// + /// The . + /// + /// An optional instance that will be used as the backing store for the cache. If not supplied, an instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The provided as . + public static ChatClientBuilder UseDistributedCache(this ChatClientBuilder builder, IDistributedCache? storage = null, Action? configure = null) + { + _ = Throw.IfNull(builder); + return builder.Use((services, innerClient) => + { + storage ??= services.GetRequiredService(); + var chatClient = new DistributedCachingChatClient(innerClient, storage); + configure?.Invoke(chatClient); + return chatClient; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs new file mode 100644 index 00000000000..c46d7f43156 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -0,0 +1,639 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating chat client that invokes functions defined on . +/// Include this in a chat pipeline to resolve function calls automatically. +/// +/// +/// When this client receives a in a chat completion, it responds +/// by calling the corresponding defined in , +/// producing a . +/// +public class FunctionInvokingChatClient : DelegatingChatClient +{ + /// Maximum number of roundtrips allowed to the inner client. + private int? _maximumIterationsPerRequest; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying , or the next instance in a chain of clients. + public FunctionInvokingChatClient(IChatClient innerClient) + : base(innerClient) + { + } + + /// + /// Gets or sets a value indicating whether to handle exceptions that occur during function calls. + /// + /// + /// + /// If the value is , then if a function call fails with an exception, the + /// underlying will be instructed to give a response without invoking + /// any further functions. + /// + /// + /// If the value is , the underlying will be allowed + /// to continue attempting function calls until is reached. + /// + /// + /// The default value is . + /// + /// + public bool RetryOnError { get; set; } + + /// + /// Gets or sets a value indicating whether detailed exception information should be included + /// in the chat history when calling the underlying . + /// + /// + /// + /// The default value is , meaning that only a generic error message will + /// be included in the chat history. This prevents the underlying language model from disclosing + /// raw exception details to the end user, since it does not receive that information. Even in this + /// case, the raw object is available to application code by inspecting + /// the property. + /// + /// + /// If set to , the full exception message will be added to the chat history + /// when calling the underlying . This can help it to bypass problems on + /// its own, for example by retrying the function call with different arguments. However it may + /// result in disclosing the raw exception information to external users, which may be a security + /// concern depending on the application scenario. + /// + /// + public bool DetailedErrors { get; set; } + + /// + /// Gets or sets a value indicating whether to allow concurrent invocation of functions. + /// + /// + /// + /// An individual response from the inner client may contain multiple function call requests. + /// By default, such function calls may be issued to execute concurrently with each other. Set + /// to false to disable such concurrent invocation and force + /// the functions to be invoked serially. + /// + /// + /// The default value is . + /// + /// + public bool ConcurrentInvocation { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to keep intermediate messages in the chat history. + /// + /// + /// When the inner returns to the + /// , the adds + /// those messages to the list of messages, along with instances + /// it creates with the results of invoking the requested functions. The resulting augmented + /// list of messages is then passed to the inner client in order to send the results back. + /// By default, is , and those + /// messages will persist in the list provided to + /// and by the caller. Set + /// to to remove those messages prior to completing the operation. + /// + public bool KeepFunctionCallingMessages { get; set; } = true; + + /// + /// Gets or sets the maximum number of iterations per request. + /// + /// + /// + /// Each request to this may end up making + /// multiple requests to the inner client. Each time the inner client responds with + /// a function call request, this client may perform that invocation and send the results + /// back to the inner client in a new request. This property limits the number of times + /// such a roundtrip is performed. If null, there is no limit applied. If set, the value + /// must be at least one, as it includes the initial request. + /// + /// + /// The default value is . + /// + /// + public int? MaximumIterationsPerRequest + { + get => _maximumIterationsPerRequest; + set + { + if (value < 1) + { + Throw.ArgumentOutOfRangeException(nameof(value)); + } + + _maximumIterationsPerRequest = value; + } + } + + /// + public override async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + ChatCompletion? response; + + HashSet? messagesToRemove = null; + HashSet? contentsToRemove = null; + try + { + for (int iteration = 0; ; iteration++) + { + // Make the call to the handler. + response = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + + // If there are no tools to call, or for any other reason we should stop, return the response. + if (options is null + || options.Tools is not { Count: > 0 } + || response.Choices.Count == 0 + || (MaximumIterationsPerRequest is { } maxIterations && iteration >= maxIterations)) + { + break; + } + + // If there's more than one choice, we don't know which one to add to chat history, or which + // of their function calls to process. This should not happen except if the developer has + // explicitly requested multiple choices. We fail aggressively to avoid cases where a developer + // doesn't realize this and is wasting their budget requesting extra choices we'd never use. + if (response.Choices.Count > 1) + { + throw new InvalidOperationException($"Automatic function call invocation only accepts a single choice, but {response.Choices.Count} choices were received."); + } + + // Extract any function call contents on the first choice. If there are none, we're done. + // We don't have any way to express a preference to use a different choice, since this + // is a niche case especially with function calling. + FunctionCallContent[] functionCallContents = response.Message.Contents.OfType().ToArray(); + if (functionCallContents.Length == 0) + { + break; + } + + // Track all added messages in order to remove them, if requested. + if (!KeepFunctionCallingMessages) + { + messagesToRemove ??= []; + } + + // Add the original response message into the history and track the message for removal. + chatMessages.Add(response.Message); + if (messagesToRemove is not null) + { + if (functionCallContents.Length == response.Message.Contents.Count) + { + // The most common case is that the response message contains only function calling content. + // In that case, we can just track the whole message for removal. + _ = messagesToRemove.Add(response.Message); + } + else + { + // In the less likely case where some content is function calling and some isn't, we don't want to remove + // the non-function calling content by removing the whole message. So we track the content directly. + (contentsToRemove ??= []).UnionWith(functionCallContents); + } + } + + // Add the responses from the function calls into the history. + var modeAndMessages = await ProcessFunctionCallsAsync(chatMessages, options, functionCallContents, iteration, cancellationToken).ConfigureAwait(false); + if (modeAndMessages.MessagesAdded is not null) + { + messagesToRemove?.UnionWith(modeAndMessages.MessagesAdded); + } + + switch (modeAndMessages.Mode) + { + case ContinueMode.Continue when options.ToolMode is RequiredChatToolMode: + // We have to reset this after the first iteration, otherwise we'll be in an infinite loop. + options = options.Clone(); + options.ToolMode = ChatToolMode.Auto; + break; + + case ContinueMode.AllowOneMoreRoundtrip: + // The LLM gets one further chance to answer, but cannot use tools. + options = options.Clone(); + options.Tools = null; + break; + + case ContinueMode.Terminate: + // Bail immediately. + return response; + } + } + + return response!; + } + finally + { + RemoveMessagesAndContentFromList(messagesToRemove, contentsToRemove, chatMessages); + } + } + + /// + public override async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + HashSet? messagesToRemove = null; + try + { + for (int iteration = 0; ; iteration++) + { + List? functionCallContents = null; + await foreach (var chunk in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) + { + // We're going to emit all StreamingChatMessage items upstream, even ones that represent + // function calls, because a given StreamingChatMessage can contain other content too. + yield return chunk; + + foreach (var item in chunk.Contents.OfType()) + { + functionCallContents ??= []; + functionCallContents.Add(item); + } + } + + // If there are no tools to call, or for any other reason we should stop, return the response. + if (options is null + || options.Tools is not { Count: > 0 } + || (MaximumIterationsPerRequest is { } maxIterations && iteration >= maxIterations) + || functionCallContents is not { Count: > 0 }) + { + break; + } + + // Track all added messages in order to remove them, if requested. + if (!KeepFunctionCallingMessages) + { + messagesToRemove ??= []; + } + + // Add a manufactured response message containing the function call contents to the chat history. + ChatMessage functionCallMessage = new(ChatRole.Assistant, [.. functionCallContents]); + chatMessages.Add(functionCallMessage); + _ = messagesToRemove?.Add(functionCallMessage); + + // Process all of the functions, adding their results into the history. + var modeAndMessages = await ProcessFunctionCallsAsync(chatMessages, options, functionCallContents, iteration, cancellationToken).ConfigureAwait(false); + if (modeAndMessages.MessagesAdded is not null) + { + messagesToRemove?.UnionWith(modeAndMessages.MessagesAdded); + } + + // Decide how to proceed based on the result of the function calls. + switch (modeAndMessages.Mode) + { + case ContinueMode.Continue when options.ToolMode is RequiredChatToolMode: + // We have to reset this after the first iteration, otherwise we'll be in an infinite loop. + options = options.Clone(); + options.ToolMode = ChatToolMode.Auto; + break; + + case ContinueMode.AllowOneMoreRoundtrip: + // The LLM gets one further chance to answer, but cannot use tools. + options = options.Clone(); + options.Tools = null; + break; + + case ContinueMode.Terminate: + // Bail immediately. + yield break; + } + } + } + finally + { + RemoveMessagesAndContentFromList(messagesToRemove, contentToRemove: null, chatMessages); + } + } + + /// + /// Removes all of the messages in from + /// and all of the content in from the messages in . + /// + private static void RemoveMessagesAndContentFromList( + HashSet? messagesToRemove, + HashSet? contentToRemove, + IList messages) + { + Debug.Assert( + contentToRemove is null || messagesToRemove is not null, + "We should only be tracking content to remove if we're also tracking messages to remove."); + + if (messagesToRemove is not null) + { + for (int m = messages.Count - 1; m >= 0; m--) + { + ChatMessage message = messages[m]; + + if (contentToRemove is not null) + { + for (int c = message.Contents.Count - 1; c >= 0; c--) + { + if (contentToRemove.Contains(message.Contents[c])) + { + message.Contents.RemoveAt(c); + } + } + } + + if (messages.Count == 0 || messagesToRemove.Contains(messages[m])) + { + messages.RemoveAt(m); + } + } + } + } + + /// + /// Processes the function calls in the list. + /// + /// The current chat contents, inclusive of the function call contents being processed. + /// The options used for the response being processed. + /// The function call contents representing the functions to be invoked. + /// The iteration number of how many roundtrips have been made to the inner client. + /// The to monitor for cancellation requests. + /// A value indicating how the caller should proceed. + private async Task<(ContinueMode Mode, IList MessagesAdded)> ProcessFunctionCallsAsync( + IList chatMessages, ChatOptions options, IReadOnlyList functionCallContents, int iteration, CancellationToken cancellationToken) + { + // We must add a response for every tool call, regardless of whether we successfully executed it or not. + // If we successfully execute it, we'll add the result. If we don't, we'll add an error. + + int functionCount = functionCallContents.Count; + Debug.Assert(functionCount > 0, $"Expecteded {nameof(functionCount)} to be > 0, got {functionCount}."); + + // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. + if (functionCount == 1) + { + FunctionInvocationResult result = await ProcessFunctionCallAsync(chatMessages, options, functionCallContents[0], iteration, 0, 1, cancellationToken).ConfigureAwait(false); + IList added = AddResponseMessages(chatMessages, [result]); + return (result.ContinueMode, added); + } + else + { + FunctionInvocationResult[] results; + + if (ConcurrentInvocation) + { + // Schedule the invocation of every function. + results = await Task.WhenAll( + from i in Enumerable.Range(0, functionCount) + select Task.Run(() => ProcessFunctionCallAsync(chatMessages, options, functionCallContents[i], iteration, i, functionCount, cancellationToken))).ConfigureAwait(false); + } + else + { + // Invoke each function serially. + results = new FunctionInvocationResult[functionCount]; + for (int i = 0; i < functionCount; i++) + { + results[i] = await ProcessFunctionCallAsync(chatMessages, options, functionCallContents[i], iteration, i, functionCount, cancellationToken).ConfigureAwait(false); + } + } + + ContinueMode continueMode = ContinueMode.Continue; + IList added = AddResponseMessages(chatMessages, results); + foreach (FunctionInvocationResult fir in results) + { + if (fir.ContinueMode > continueMode) + { + continueMode = fir.ContinueMode; + } + } + + return (continueMode, added); + } + } + + /// Processes the function call described in . + /// The current chat contents, inclusive of the function call contents being processed. + /// The options used for the response being processed. + /// The function call content representing the function to be invoked. + /// The iteration number of how many roundtrips have been made to the inner client. + /// The 0-based index of the function being called out of total functions. + /// The number of function call requests made, of which this is one. + /// The to monitor for cancellation requests. + /// A value indicating how the caller should proceed. + private async Task ProcessFunctionCallAsync( + IList chatMessages, ChatOptions options, FunctionCallContent functionCallContent, + int iteration, int functionCallIndex, int totalFunctionCount, CancellationToken cancellationToken) + { + // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. + AIFunction? function = options.Tools!.OfType().FirstOrDefault(t => t.Metadata.Name == functionCallContent.Name); + if (function is null) + { + return new(ContinueMode.Continue, FunctionStatus.NotFound, functionCallContent, result: null, exception: null); + } + + FunctionInvocationContext context = new(chatMessages, functionCallContent, function) + { + Iteration = iteration, + FunctionCallIndex = functionCallIndex, + FunctionCount = totalFunctionCount, + }; + + try + { + object? result = await InvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false); + return new( + context.Terminate ? ContinueMode.Terminate : ContinueMode.Continue, + FunctionStatus.CompletedSuccessfully, + functionCallContent, + result, + exception: null); + } + catch (Exception e) when (!cancellationToken.IsCancellationRequested) + { + return new( + RetryOnError ? ContinueMode.Continue : ContinueMode.AllowOneMoreRoundtrip, // We won't allow further function calls, hence the LLM will just get one more chance to give a final answer. + FunctionStatus.Failed, + functionCallContent, + result: null, + exception: e); + } + } + + /// Represents the return value of , dictating how the loop should behave. + /// These values are ordered from least severe to most severe, and code explicitly depends on the ordering. + internal enum ContinueMode + { + /// Send back the responses and continue processing. + Continue = 0, + + /// Send back the response but without any tools. + AllowOneMoreRoundtrip = 1, + + /// Immediately exit the function calling loop. + Terminate = 2, + } + + /// Adds one or more response messages for function invocation results. + /// The chat to which to add the one or more response messages. + /// Information about the function call invocations and results. + /// A list of all chat messages added to . + protected virtual IList AddResponseMessages(IList chat, ReadOnlySpan results) + { + _ = Throw.IfNull(chat); + + var contents = new AIContent[results.Length]; + for (int i = 0; i < results.Length; i++) + { + contents[i] = CreateFunctionResultContent(results[i]); + } + + ChatMessage message = new(ChatRole.Tool, contents); + chat.Add(message); + return [message]; + + FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result) + { + _ = Throw.IfNull(result); + + object? functionResult; + if (result.Status == FunctionStatus.CompletedSuccessfully) + { + functionResult = result.Result ?? "Success: Function completed."; + } + else + { + string message = result.Status switch + { + FunctionStatus.NotFound => "Error: Requested function not found.", + FunctionStatus.Failed => "Error: Function failed.", + _ => "Error: Unknown error.", + }; + + if (DetailedErrors && result.Exception is not null) + { + message = $"{message} Exception: {result.Exception.Message}"; + } + + functionResult = message; + } + + return new FunctionResultContent(result.CallContent.CallId, result.CallContent.Name, functionResult, result.Exception); + } + } + + /// Invokes the function asynchronously. + /// + /// The function invocation context detailing the function to be invoked and its arguments along with additional request information. + /// + /// The to monitor for cancellation requests. The default is . + /// The result of the function invocation. This may be null if the function invocation returned null. + protected virtual Task InvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) + { + _ = Throw.IfNull(context); + + return context.Function.InvokeAsync(context.CallContent.Arguments, cancellationToken); + } + + /// Provides context for a function invocation. + public sealed class FunctionInvocationContext + { + /// Initializes a new instance of the class. + /// The chat contents associated with the operation that initiated this function call request. + /// The AI function to be invoked. + /// The function call content information associated with this invocation. + internal FunctionInvocationContext( + IList chatMessages, + FunctionCallContent functionCallContent, + AIFunction function) + { + Function = function; + CallContent = functionCallContent; + ChatMessages = chatMessages; + } + + /// Gets or sets the AI function to be invoked. + public AIFunction Function { get; set; } + + /// Gets or sets the function call content information associated with this invocation. + public FunctionCallContent CallContent { get; set; } + + /// Gets or sets the chat contents associated with the operation that initiated this function call request. + public IList ChatMessages { get; set; } + + /// Gets or sets the number of this iteration with the underlying client. + /// + /// The initial request to the client that passes along the chat contents provided to the + /// is iteration 1. If the client responds with a function call request, the next request to the client is iteration 2, and so on. + /// + public int Iteration { get; set; } + + /// Gets or sets the index of the function call within the iteration. + /// + /// The response from the underlying client may include multiple function call requests. + /// This index indicates the position of the function call within the iteration. + /// + public int FunctionCallIndex { get; set; } + + /// Gets or sets the total number of function call requests within the iteration. + /// + /// The response from the underlying client may include multiple function call requests. + /// This count indicates how many there were. + /// + public int FunctionCount { get; set; } + + /// Gets or sets a value indicating whether to terminate the request. + /// + /// In response to a function call request, the function may be invoked, its result added to the chat contents, + /// and a new request issued to the wrapped client. If this property is set to true, that subsequent request + /// will not be issued and instead the loop immediately terminated rather than continuing until there are no + /// more function call requests in responses. + /// + public bool Terminate { get; set; } + } + + /// Provides information about the invocation of a function call. + public sealed class FunctionInvocationResult + { + internal FunctionInvocationResult(ContinueMode continueMode, FunctionStatus status, FunctionCallContent callContent, object? result, Exception? exception) + { + ContinueMode = continueMode; + Status = status; + CallContent = callContent; + Result = result; + Exception = exception; + } + + /// Gets status about how the function invocation completed. + public FunctionStatus Status { get; } + + /// Gets the function call content information associated with this invocation. + public FunctionCallContent CallContent { get; } + + /// Gets the result of the function call. + public object? Result { get; } + + /// Gets any exception the function call threw. + public Exception? Exception { get; } + + /// Gets an indication for how the caller should continue the processing loop. + internal ContinueMode ContinueMode { get; } + } + + /// Provides error codes for when errors occur as part of the function calling loop. + public enum FunctionStatus + { + /// The operation completed successfully. + CompletedSuccessfully, + + /// The requested function could not be found. + NotFound, + + /// The function call failed with an exception. + Failed, + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..15010b42068 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.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; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods for attaching a to a chat pipeline. +/// +public static class FunctionInvokingChatClientBuilderExtensions +{ + /// + /// Enables automatic function call invocation on the chat pipeline. + /// + /// This works by adding an instance of with default options. + /// The being used to build the chat pipeline. + /// An optional callback that can be used to configure the instance. + /// The supplied . + public static ChatClientBuilder UseFunctionInvocation(this ChatClientBuilder builder, Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use(innerClient => + { + var chatClient = new FunctionInvokingChatClient(innerClient); + configure?.Invoke(chatClient); + return chatClient; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs new file mode 100644 index 00000000000..f0a9e8a0d75 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs @@ -0,0 +1,154 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable EA0000 // Use source generated logging methods for improved performance +#pragma warning disable CA2254 // Template should be a static expression + +namespace Microsoft.Extensions.AI; + +/// A delegating chat client that logs chat operations to an . +public class LoggingChatClient : DelegatingChatClient +{ + /// An instance used for all logging. + private readonly ILogger _logger; + + /// The to use for serialization of state written to the logger. + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used for all logging. + public LoggingChatClient(IChatClient innerClient, ILogger logger) + : base(innerClient) + { + _logger = Throw.IfNull(logger); + _jsonSerializerOptions = JsonDefaults.Options; + } + + /// Gets or sets JSON serialization options to use when serializing logging data. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public override async Task CompleteAsync( + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + LogStart(chatMessages, options); + try + { + var completion = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.Log(LogLevel.Trace, 0, (completion, _jsonSerializerOptions), null, static (state, _) => + $"CompleteAsync completed: {JsonSerializer.Serialize(state.completion, state._jsonSerializerOptions.GetTypeInfo(typeof(ChatCompletion)))}"); + } + else + { + _logger.LogDebug("CompleteAsync completed."); + } + } + + return completion; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "CompleteAsync failed."); + throw; + } + } + + /// + public override async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + LogStart(chatMessages, options); + + IAsyncEnumerator e; + try + { + e = base.CompleteStreamingAsync(chatMessages, options, cancellationToken).GetAsyncEnumerator(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "CompleteStreamingAsync failed."); + throw; + } + + try + { + StreamingChatCompletionUpdate? update = null; + while (true) + { + try + { + if (!await e.MoveNextAsync().ConfigureAwait(false)) + { + break; + } + + update = e.Current; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "CompleteStreamingAsync failed."); + throw; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.Log(LogLevel.Trace, 0, (update, _jsonSerializerOptions), null, static (state, _) => + $"CompleteStreamingAsync received update: {JsonSerializer.Serialize(state.update, state._jsonSerializerOptions.GetTypeInfo(typeof(StreamingChatCompletionUpdate)))}"); + } + else + { + _logger.LogDebug("CompleteStreamingAsync received update."); + } + } + + yield return update; + } + + _logger.LogDebug("CompleteStreamingAsync completed."); + } + finally + { + await e.DisposeAsync().ConfigureAwait(false); + } + } + + private void LogStart(IList chatMessages, ChatOptions? options, [CallerMemberName] string? methodName = null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.Log(LogLevel.Trace, 0, (methodName, chatMessages, options, this), null, static (state, _) => + $"{state.methodName} invoked: " + + $"Messages: {JsonSerializer.Serialize(state.chatMessages, state.Item4._jsonSerializerOptions.GetTypeInfo(typeof(IList)))}. " + + $"Options: {JsonSerializer.Serialize(state.options, state.Item4._jsonSerializerOptions.GetTypeInfo(typeof(ChatOptions)))}. " + + $"Metadata: {JsonSerializer.Serialize(state.Item4.Metadata, state.Item4._jsonSerializerOptions.GetTypeInfo(typeof(ChatClientMetadata)))}."); + } + else + { + _logger.LogDebug($"{methodName} invoked."); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..056ba5401fc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +public static class LoggingChatClientBuilderExtensions +{ + /// Adds logging to the chat client pipeline. + /// The . + /// + /// An optional with which logging should be performed. If not supplied, an instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The . + public static ChatClientBuilder UseLogging( + this ChatClientBuilder builder, ILogger? logger = null, Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((services, innerClient) => + { + logger ??= services.GetRequiredService().CreateLogger(nameof(LoggingChatClient)); + var chatClient = new LoggingChatClient(innerClient, logger); + configure?.Invoke(chatClient); + return chatClient; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs new file mode 100644 index 00000000000..13e2d1229dd --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -0,0 +1,509 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Collections; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// +/// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/. +/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. +/// +public sealed class OpenTelemetryChatClient : DelegatingChatClient +{ + private readonly ActivitySource _activitySource; + private readonly Meter _meter; + + private readonly Histogram _tokenUsageHistogram; + private readonly Histogram _operationDurationHistogram; + + private readonly string? _modelId; + private readonly string? _modelProvider; + private readonly string? _endpointAddress; + private readonly int _endpointPort; + + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// An optional source name that will be used on the telemetry data. + public OpenTelemetryChatClient(IChatClient innerClient, string? sourceName = null) + : base(innerClient) + { + Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); + + ChatClientMetadata metadata = innerClient!.Metadata; + _modelId = metadata.ModelId; + _modelProvider = metadata.ProviderName; + _endpointAddress = metadata.ProviderUri?.GetLeftPart(UriPartial.Path); + _endpointPort = metadata.ProviderUri?.Port ?? 0; + + string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + _activitySource = new(name); + _meter = new(name); + + _tokenUsageHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, + OpenTelemetryConsts.TokensUnit, + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries }); + + _operationDurationHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, + OpenTelemetryConsts.SecondsUnit, + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries }); + + _jsonSerializerOptions = JsonDefaults.Options; + } + + /// Gets or sets JSON serialization options to use when formatting chat data into telemetry strings. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activitySource.Dispose(); + _meter.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Gets or sets a value indicating whether potentially sensitive information (e.g. prompts) should be included in telemetry. + /// + /// + /// The value is by default, meaning that telemetry will include metadata such as token counts but not the raw text of prompts or completions. + /// + public bool EnableSensitiveData { get; set; } + + /// + public override async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _jsonSerializerOptions.MakeReadOnly(); + + using Activity? activity = StartActivity(chatMessages, options); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _modelId; + + ChatCompletion? response = null; + Exception? error = null; + try + { + response = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + SetCompletionResponse(activity, requestModelId, response, error, stopwatch); + } + + return response; + } + + /// + public override async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _jsonSerializerOptions.MakeReadOnly(); + + using Activity? activity = StartActivity(chatMessages, options); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _modelId; + + IAsyncEnumerable response; + try + { + response = base.CompleteStreamingAsync(chatMessages, options, cancellationToken); + } + catch (Exception ex) + { + SetCompletionResponse(activity, requestModelId, null, ex, stopwatch); + throw; + } + + var responseEnumerator = response.ConfigureAwait(false).GetAsyncEnumerator(); + List? streamedContents = activity is not null ? [] : null; + try + { + while (true) + { + StreamingChatCompletionUpdate update; + try + { + if (!await responseEnumerator.MoveNextAsync()) + { + break; + } + + update = responseEnumerator.Current; + } + catch (Exception ex) + { + SetCompletionResponse(activity, requestModelId, null, ex, stopwatch); + throw; + } + + streamedContents?.Add(update); + yield return update; + } + } + finally + { + if (activity is not null) + { + UsageContent? usageContent = streamedContents?.SelectMany(c => c.Contents).OfType().LastOrDefault(); + SetCompletionResponse( + activity, + stopwatch, + requestModelId, + OrganizeStreamingContent(streamedContents), + streamedContents?.SelectMany(c => c.Contents).OfType(), + usage: usageContent?.Details); + } + + await responseEnumerator.DisposeAsync(); + } + } + + /// Gets a value indicating whether diagnostics are enabled. + private bool Enabled => _activitySource.HasListeners(); + + /// Convert chat history to a string aligned with the OpenAI format. + private static string ToOpenAIFormat(IEnumerable messages, JsonSerializerOptions serializerOptions) + { + var sb = new StringBuilder().Append('['); + + string messageSeparator = string.Empty; + foreach (var message in messages) + { + _ = sb.Append(messageSeparator); + messageSeparator = ", \n"; + + string text = string.Concat(message.Contents.OfType().Select(c => c.Text)); + _ = sb.Append("{\"role\": \"").Append(message.Role).Append("\", \"content\": ").Append(JsonSerializer.Serialize(text, serializerOptions.GetTypeInfo(typeof(string)))); + + if (message.Contents.OfType().Any()) + { + _ = sb.Append(", \"tool_calls\": ").Append('['); + + string messageItemSeparator = string.Empty; + foreach (var functionCall in message.Contents.OfType()) + { + _ = sb.Append(messageItemSeparator); + messageItemSeparator = ", \n"; + + _ = sb.Append("{\"id\": \"").Append(functionCall.CallId) + .Append("\", \"function\": {\"arguments\": ").Append(JsonSerializer.Serialize(functionCall.Arguments, serializerOptions.GetTypeInfo(typeof(IDictionary)))) + .Append(", \"name\": \"").Append(functionCall.Name) + .Append("\"}, \"type\": \"function\"}"); + } + + _ = sb.Append(']'); + } + + _ = sb.Append('}'); + } + + _ = sb.Append(']'); + return sb.ToString(); + } + + /// Organize streaming content by choice index. + private static Dictionary> OrganizeStreamingContent(IEnumerable? contents) + { + Dictionary> choices = []; + if (contents is null) + { + return choices; + } + + foreach (var content in contents) + { + if (!choices.TryGetValue(content.ChoiceIndex, out var choiceContents)) + { + choices[content.ChoiceIndex] = choiceContents = []; + } + + choiceContents.Add(content); + } + + return choices; + } + + /// Creates an activity for a chat completion request, or returns null if not enabled. + private Activity? StartActivity(IList chatMessages, ChatOptions? options) + { + Activity? activity = null; + if (Enabled) + { + string? modelId = options?.ModelId ?? _modelId; + + activity = _activitySource.StartActivity( + $"chat.completions {modelId}", + ActivityKind.Client, + default(ActivityContext), + [ + new(OpenTelemetryConsts.GenAI.Operation.Name, "chat"), + new(OpenTelemetryConsts.GenAI.Request.Model, modelId), + new(OpenTelemetryConsts.GenAI.System, _modelProvider), + ]); + + if (activity is not null) + { + if (_endpointAddress is not null) + { + _ = activity + .SetTag(OpenTelemetryConsts.Server.Address, _endpointAddress) + .SetTag(OpenTelemetryConsts.Server.Port, _endpointPort); + } + + if (options is not null) + { + if (options.FrequencyPenalty is float frequencyPenalty) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.FrequencyPenalty, frequencyPenalty); + } + + if (options.MaxOutputTokens is int maxTokens) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.MaxTokens, maxTokens); + } + + if (options.PresencePenalty is float presencePenalty) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.PresencePenalty, presencePenalty); + } + + if (options.StopSequences is IList stopSequences) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.StopSequences, $"[{string.Join(", ", stopSequences.Select(s => $"\"{s}\""))}]"); + } + + if (options.Temperature is float temperature) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.Temperature, temperature); + } + + if (options.AdditionalProperties?.TryGetConvertedValue("top_k", out double topK) is true) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.TopK, topK); + } + + if (options.TopP is float top_p) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.TopP, top_p); + } + } + + if (EnableSensitiveData) + { + _ = activity.AddEvent(new ActivityEvent( + OpenTelemetryConsts.GenAI.Content.Prompt, + tags: new ActivityTagsCollection([new(OpenTelemetryConsts.GenAI.Prompt, ToOpenAIFormat(chatMessages, _jsonSerializerOptions))]))); + } + } + } + + return activity; + } + + /// Adds chat completion information to the activity. + private void SetCompletionResponse( + Activity? activity, + string? requestModelId, + ChatCompletion? completions, + Exception? error, + Stopwatch? stopwatch) + { + if (!Enabled) + { + return; + } + + if (_operationDurationHistogram.Enabled && stopwatch is not null) + { + TagList tags = default; + + AddMetricTags(ref tags, requestModelId, completions); + if (error is not null) + { + tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); + } + + _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); + } + + if (_tokenUsageHistogram.Enabled && completions?.Usage is { } usage) + { + if (usage.InputTokenCount is int inputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "input"); + AddMetricTags(ref tags, requestModelId, completions); + _tokenUsageHistogram.Record(inputTokens); + } + + if (usage.OutputTokenCount is int outputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "output"); + AddMetricTags(ref tags, requestModelId, completions); + _tokenUsageHistogram.Record(outputTokens); + } + } + + if (activity is null) + { + return; + } + + if (error is not null) + { + _ = activity + .SetTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + return; + } + + if (completions is not null) + { + if (completions.FinishReason is ChatFinishReason finishReason) + { +#pragma warning disable CA1308 // Normalize strings to uppercase + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.FinishReasons, $"[\"{finishReason.Value.ToLowerInvariant()}\"]"); +#pragma warning restore CA1308 + } + + if (!string.IsNullOrWhiteSpace(completions.CompletionId)) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.Id, completions.CompletionId); + } + + if (completions.ModelId is not null) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.Model, completions.ModelId); + } + + if (completions.Usage?.InputTokenCount is int inputTokens) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.InputTokens, inputTokens); + } + + if (completions.Usage?.OutputTokenCount is int outputTokens) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.OutputTokens, outputTokens); + } + + if (EnableSensitiveData) + { + _ = activity.AddEvent(new ActivityEvent( + OpenTelemetryConsts.GenAI.Content.Completion, + tags: new ActivityTagsCollection([new(OpenTelemetryConsts.GenAI.Completion, ToOpenAIFormat(completions.Choices, _jsonSerializerOptions))]))); + } + } + } + + /// Adds streaming chat completion information to the activity. + private void SetCompletionResponse( + Activity? activity, + Stopwatch? stopwatch, + string? requestModelId, + Dictionary> choices, + IEnumerable? toolCalls, + UsageDetails? usage) + { + if (activity is null || !Enabled || choices.Count == 0) + { + return; + } + + string? id = null; + ChatFinishReason? finishReason = null; + string? modelId = null; + List messages = new(choices.Count); + + foreach (var choice in choices) + { + ChatRole? role = null; + List items = []; + foreach (var update in choice.Value) + { + id ??= update.CompletionId; + role ??= update.Role; + finishReason ??= update.FinishReason; + foreach (AIContent content in update.Contents) + { + items.Add(content); + modelId ??= content.ModelId; + } + } + + messages.Add(new ChatMessage(role ?? ChatRole.Assistant, items)); + } + + if (toolCalls is not null && messages.FirstOrDefault()?.Contents is { } c) + { + foreach (var functionCall in toolCalls) + { + c.Add(functionCall); + } + } + + ChatCompletion completion = new(messages) + { + CompletionId = id, + FinishReason = finishReason, + ModelId = modelId, + Usage = usage, + }; + + SetCompletionResponse(activity, requestModelId, completion, error: null, stopwatch); + } + + private void AddMetricTags(ref TagList tags, string? requestModelId, ChatCompletion? completions) + { + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, "chat"); + + if (requestModelId is not null) + { + tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); + } + + tags.Add(OpenTelemetryConsts.GenAI.System, _modelProvider); + + if (_endpointAddress is string endpointAddress) + { + tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Port, _endpointPort); + } + + if (completions?.ModelId is string responseModel) + { + tags.Add(OpenTelemetryConsts.GenAI.Response.Model, responseModel); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..bf1ff4e9f0d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs @@ -0,0 +1,31 @@ +// 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.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +public static class OpenTelemetryChatClientBuilderExtensions +{ + /// + /// Adds OpenTelemetry support to the chat client pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// + /// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/. + /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. + /// + /// The . + /// An optional source name that will be used on the telemetry data. + /// An optional callback that can be used to configure the instance. + /// The . + public static ChatClientBuilder UseOpenTelemetry( + this ChatClientBuilder builder, string? sourceName = null, Action? configure = null) => + Throw.IfNull(builder).Use(innerClient => + { + var chatClient = new OpenTelemetryChatClient(innerClient, sourceName); + configure?.Invoke(chatClient); + return chatClient; + }); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs new file mode 100644 index 00000000000..8438d467eb6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating embedding generator that caches the results of embedding generation calls. +/// The type from which embeddings will be generated. +/// The type of embeddings to generate. +public abstract class CachingEmbeddingGenerator : DelegatingEmbeddingGenerator + where TEmbedding : Embedding +{ + /// Initializes a new instance of the class. + /// The underlying . + protected CachingEmbeddingGenerator(IEmbeddingGenerator innerGenerator) + : base(innerGenerator) + { + } + + /// + public override async Task> GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(values); + + // Optimize for the common-case of a single value in a list/array. + if (values is IList valuesList) + { + switch (valuesList.Count) + { + case 0: + return []; + + case 1: + // In the expected common case where we can cheaply tell there's only a single value and access it, + // we can avoid all the overhead of splitting the list and reassembling it. + var cacheKey = GetCacheKey(valuesList[0], options); + if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is TEmbedding e) + { + return [e]; + } + else + { + var generated = await base.GenerateAsync(valuesList, options, cancellationToken).ConfigureAwait(false); + if (generated.Count != 1) + { + throw new InvalidOperationException($"Expected exactly one embedding to be generated, but received {generated.Count}."); + } + + await WriteCacheAsync(cacheKey, generated[0], cancellationToken).ConfigureAwait(false); + return generated; + } + } + } + + // Some of the inputs may already be cached. Go through each, checking to see whether each individually is cached. + // Split those that are cached into one list and those that aren't into another. We retain their original positions + // so that we can reassemble the results in the correct order. + GeneratedEmbeddings results = []; + List<(int Index, string CacheKey, TInput Input)>? uncached = null; + foreach (TInput input in values) + { + // We're only storing the final result, not the in-flight task, so that we can avoid caching failures + // or having problems when one of the callers cancels but others don't. This has the drawback that + // concurrent callers might trigger duplicate requests, but that's acceptable. + var cacheKey = GetCacheKey(input, options); + + if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is TEmbedding existing) + { + results.Add(existing); + } + else + { + (uncached ??= []).Add((results.Count, cacheKey, input)); + results.Add(null!); // temporary placeholder + } + } + + // If anything wasn't cached, we need to generate embeddings for those. + if (uncached is not null) + { + // Now make a single call to the wrapped generator to generate embeddings for all of the uncached inputs. + var uncachedResults = await base.GenerateAsync(uncached.Select(e => e.Input), options, cancellationToken).ConfigureAwait(false); + + // Store the resulting embeddings into the cache individually. + for (int i = 0; i < uncachedResults.Count; i++) + { + await WriteCacheAsync(uncached[i].CacheKey, uncachedResults[i], cancellationToken).ConfigureAwait(false); + } + + // Fill in the gaps with the newly generated results. + for (int i = 0; i < uncachedResults.Count; i++) + { + results[uncached[i].Index] = uncachedResults[i]; + } + } + + Debug.Assert(results.All(e => e is not null), "Expected all values to be non-null"); + return results; + } + + /// + /// Computes a cache key for the specified call parameters. + /// + /// The for which an embedding is being requested. + /// The options to configure the request. + /// A string that will be used as a cache key. + protected abstract string GetCacheKey(TInput value, EmbeddingGenerationOptions? options); + + /// Returns a previously cached , if available. + /// The cache key. + /// The to monitor for cancellation requests. + /// The previously cached data, if available, otherwise . + protected abstract Task ReadCacheAsync(string key, CancellationToken cancellationToken); + + /// Stores a in the underlying cache. + /// The cache key. + /// The to be stored. + /// The to monitor for cancellation requests. + /// A representing the completion of the operation. + protected abstract Task WriteCacheAsync(string key, TEmbedding value, CancellationToken cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs new file mode 100644 index 00000000000..932bb2f91b8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs @@ -0,0 +1,81 @@ +// 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; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating embedding generator that caches the results of embedding generation calls, +/// storing them as JSON in an . +/// +/// The type from which embeddings will be generated. +/// The type of embeddings to generate. +public class DistributedCachingEmbeddingGenerator : CachingEmbeddingGenerator + where TEmbedding : Embedding +{ + private readonly IDistributedCache _storage; + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// A instance that will be used as the backing store for the cache. + public DistributedCachingEmbeddingGenerator(IEmbeddingGenerator innerGenerator, IDistributedCache storage) + : base(innerGenerator) + { + _ = Throw.IfNull(storage); + _storage = storage; + _jsonSerializerOptions = JsonDefaults.Options; + } + + /// Gets or sets JSON serialization options to use when serializing cache data. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set + { + _ = Throw.IfNull(value); + _jsonSerializerOptions = value; + } + } + + /// + protected override async Task ReadCacheAsync(string key, CancellationToken cancellationToken) + { + _ = Throw.IfNull(key); + _jsonSerializerOptions.MakeReadOnly(); + + if (await _storage.GetAsync(key, cancellationToken).ConfigureAwait(false) is byte[] existingJson) + { + return JsonSerializer.Deserialize(existingJson, (JsonTypeInfo)_jsonSerializerOptions.GetTypeInfo(typeof(TEmbedding))); + } + + return null; + } + + /// + protected override async Task WriteCacheAsync(string key, TEmbedding value, CancellationToken cancellationToken) + { + _ = Throw.IfNull(key); + _ = Throw.IfNull(value); + _jsonSerializerOptions.MakeReadOnly(); + + var newJson = JsonSerializer.SerializeToUtf8Bytes(value, (JsonTypeInfo)_jsonSerializerOptions.GetTypeInfo(typeof(TEmbedding))); + await _storage.SetAsync(key, newJson, cancellationToken).ConfigureAwait(false); + } + + /// + protected override string GetCacheKey(TInput value, EmbeddingGenerationOptions? options) + { + // While it might be desirable to include options in the cache key, it's not always possible, + // since options can contain types that are not guaranteed to be serializable or have a stable + // hashcode across multiple calls. So the default cache key is simply the JSON representation of + // the value. Developers may subclass and override this to provide custom rules. + return CachingHelpers.GetCacheKey(value, _jsonSerializerOptions); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..77aaa30e05d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGeneratorBuilderExtensions.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Extension methods for adding a to an +/// pipeline. +/// +public static class DistributedCachingEmbeddingGeneratorBuilderExtensions +{ + /// + /// Adds a as the next stage in the pipeline. + /// + /// The type from which embeddings will be generated. + /// The type of embeddings to generate. + /// The . + /// + /// An optional instance that will be used as the backing store for the cache. If not supplied, an instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The provided as . + public static EmbeddingGeneratorBuilder UseDistributedCache( + this EmbeddingGeneratorBuilder builder, + IDistributedCache? storage = null, + Action>? configure = null) + where TEmbedding : Embedding + { + _ = Throw.IfNull(builder); + return builder.Use((services, innerGenerator) => + { + storage ??= services.GetRequiredService(); + var result = new DistributedCachingEmbeddingGenerator(innerGenerator, storage); + configure?.Invoke(result); + return result; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs new file mode 100644 index 00000000000..96c4c92d4a9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A builder for creating pipelines of . +/// The type from which embeddings will be generated. +/// The type of embeddings to generate. +public sealed class EmbeddingGeneratorBuilder + where TEmbedding : Embedding +{ + /// The registered client factory instances. + private List, IEmbeddingGenerator>>? _generatorFactories; + + /// Initializes a new instance of the class. + /// The service provider to use for dependency injection. + public EmbeddingGeneratorBuilder(IServiceProvider? services = null) + { + Services = services ?? EmptyServiceProvider.Instance; + } + + /// Gets the associated with the builder instance. + public IServiceProvider Services { get; } + + /// + /// Builds an instance of using the specified inner generator. + /// + /// The inner generator to use. + /// An instance of . + /// + /// If there are any factories registered with this builder, is used as a seed to + /// the last factory, and the result of each factory delegate is passed to the previously registered factory. + /// The final result is then returned from this call. + /// + public IEmbeddingGenerator Use(IEmbeddingGenerator innerGenerator) + { + var embeddingGenerator = Throw.IfNull(innerGenerator); + + // To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost. + if (_generatorFactories is not null) + { + for (var i = _generatorFactories.Count - 1; i >= 0; i--) + { + embeddingGenerator = _generatorFactories[i](Services, embeddingGenerator) ?? + throw new InvalidOperationException( + $"The {nameof(IEmbeddingGenerator)} entry at index {i} returned null. " + + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IEmbeddingGenerator)} instances."); + } + } + + return embeddingGenerator; + } + + /// Adds a factory for an intermediate embedding generator to the embedding generator pipeline. + /// The generator factory function. + /// The updated instance. + public EmbeddingGeneratorBuilder Use(Func, IEmbeddingGenerator> generatorFactory) + { + _ = Throw.IfNull(generatorFactory); + + return Use((_, innerGenerator) => generatorFactory(innerGenerator)); + } + + /// Adds a factory for an intermediate embedding generator to the embedding generator pipeline. + /// The generator factory function. + /// The updated instance. + public EmbeddingGeneratorBuilder Use(Func, IEmbeddingGenerator> generatorFactory) + { + _ = Throw.IfNull(generatorFactory); + + _generatorFactories ??= []; + _generatorFactories.Add(generatorFactory); + return this; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs new file mode 100644 index 00000000000..369de130e72 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for registering with a . +public static class EmbeddingGeneratorBuilderServiceCollectionExtensions +{ + /// Adds a embedding generator to the . + /// The type from which embeddings will be generated. + /// The type of embeddings to generate. + /// The to which the generator should be added. + /// The factory to use to construct the instance. + /// The collection. + /// The generator is registered as a scoped service. + public static IServiceCollection AddEmbeddingGenerator( + this IServiceCollection services, + Func, IEmbeddingGenerator> generatorFactory) + where TEmbedding : Embedding + { + _ = Throw.IfNull(services); + _ = Throw.IfNull(generatorFactory); + + return services.AddScoped(services => + generatorFactory(new EmbeddingGeneratorBuilder(services))); + } + + /// Adds an embedding generator to the . + /// The type from which embeddings will be generated. + /// The type of embeddings to generate. + /// The to which the service should be added. + /// The key with which to associated the generator. + /// The factory to use to construct the instance. + /// The collection. + /// The generator is registered as a scoped service. + public static IServiceCollection AddKeyedEmbeddingGenerator( + this IServiceCollection services, + object serviceKey, + Func, IEmbeddingGenerator> generatorFactory) + where TEmbedding : Embedding + { + _ = Throw.IfNull(services); + _ = Throw.IfNull(serviceKey); + _ = Throw.IfNull(generatorFactory); + + return services.AddKeyedScoped(serviceKey, (services, _) => + generatorFactory(new EmbeddingGeneratorBuilder(services))); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs new file mode 100644 index 00000000000..b7981de8129 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable EA0000 // Use source generated logging methods for improved performance + +namespace Microsoft.Extensions.AI; + +/// A delegating embedding generator that logs embedding generation operations to an . +/// Specifies the type of the input passed to the generator. +/// Specifies the type of the embedding instance produced by the generator. +public class LoggingEmbeddingGenerator : DelegatingEmbeddingGenerator + where TEmbedding : Embedding +{ + /// An instance used for all logging. + private readonly ILogger _logger; + + /// The to use for serialization of state written to the logger. + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used for all logging. + public LoggingEmbeddingGenerator(IEmbeddingGenerator innerGenerator, ILogger logger) + : base(innerGenerator) + { + _logger = Throw.IfNull(logger); + _jsonSerializerOptions = JsonDefaults.Options; + } + + /// Gets or sets JSON serialization options to use when serializing logging data. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public override async Task> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.Log(LogLevel.Trace, 0, (values, options, this), null, static (state, _) => + "GenerateAsync invoked: " + + $"Values: {JsonSerializer.Serialize(state.values, state.Item3._jsonSerializerOptions.GetTypeInfo(typeof(IEnumerable)))}. " + + $"Options: {JsonSerializer.Serialize(state.options, state.Item3._jsonSerializerOptions.GetTypeInfo(typeof(EmbeddingGenerationOptions)))}. " + + $"Metadata: {JsonSerializer.Serialize(state.Item3.Metadata, state.Item3._jsonSerializerOptions.GetTypeInfo(typeof(EmbeddingGeneratorMetadata)))}."); + } + else + { + _logger.LogDebug("GenerateAsync invoked."); + } + } + + try + { + var embeddings = await base.GenerateAsync(values, options, cancellationToken).ConfigureAwait(false); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("GenerateAsync generated {Count} embedding(s).", embeddings.Count); + } + + return embeddings; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "GenerateAsync failed."); + throw; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..1335a3fd8d3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +public static class LoggingEmbeddingGeneratorBuilderExtensions +{ + /// Adds logging to the embedding generator pipeline. + /// Specifies the type of the input passed to the generator. + /// Specifies the type of the embedding instance produced by the generator. + /// The . + /// + /// An optional with which logging should be performed. If not supplied, an instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The . + public static EmbeddingGeneratorBuilder UseLogging( + this EmbeddingGeneratorBuilder builder, ILogger? logger = null, Action>? configure = null) + where TEmbedding : Embedding + { + _ = Throw.IfNull(builder); + + return builder.Use((services, innerGenerator) => + { + logger ??= services.GetRequiredService().CreateLogger(nameof(LoggingEmbeddingGenerator)); + var generator = new LoggingEmbeddingGenerator(innerGenerator, logger); + configure?.Invoke(generator); + return generator; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs new file mode 100644 index 00000000000..8105cc64bdf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -0,0 +1,239 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// +/// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/. +/// The specification is still experimental and subject to change; as such, the telemetry output by this generator is also subject to change. +/// +/// The type of input used to produce embeddings. +/// The type of embedding generated. +public sealed class OpenTelemetryEmbeddingGenerator : DelegatingEmbeddingGenerator + where TEmbedding : Embedding +{ + private readonly ActivitySource _activitySource; + private readonly Meter _meter; + + private readonly Histogram _tokenUsageHistogram; + private readonly Histogram _operationDurationHistogram; + + private readonly string? _modelId; + private readonly string? _modelProvider; + private readonly string? _endpointAddress; + private readonly int _endpointPort; + private readonly int? _dimensions; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying , which is the next stage of the pipeline. + /// An optional source name that will be used on the telemetry data. + public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator innerGenerator, string? sourceName = null) + : base(innerGenerator) + { + Debug.Assert(innerGenerator is not null, "Should have been validated by the base ctor."); + + EmbeddingGeneratorMetadata metadata = innerGenerator!.Metadata; + _modelId = metadata.ModelId; + _modelProvider = metadata.ProviderName; + _endpointAddress = metadata.ProviderUri?.GetLeftPart(UriPartial.Path); + _endpointPort = metadata.ProviderUri?.Port ?? 0; + _dimensions = metadata.Dimensions; + + string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + _activitySource = new(name); + _meter = new(name); + + _tokenUsageHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, + OpenTelemetryConsts.TokensUnit, + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries }); + + _operationDurationHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, + OpenTelemetryConsts.SecondsUnit, + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries }); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activitySource.Dispose(); + _meter.Dispose(); + } + + base.Dispose(disposing); + } + + /// Gets a value indicating whether diagnostics are enabled. + private bool Enabled => _activitySource.HasListeners(); + + /// + public override async Task> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(values); + + using Activity? activity = StartActivity(); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + + GeneratedEmbeddings? response = null; + Exception? error = null; + try + { + response = await base.GenerateAsync(values, options, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + SetCompletionResponse(activity, response, error, stopwatch); + } + + return response; + } + + /// Creates an activity for an embedding generation request, or returns null if not enabled. + private Activity? StartActivity() + { + Activity? activity = null; + if (Enabled) + { + activity = _activitySource.StartActivity( + $"embedding {_modelId}", + ActivityKind.Client, + default(ActivityContext), + [ + new(OpenTelemetryConsts.GenAI.Operation.Name, "embedding"), + new(OpenTelemetryConsts.GenAI.Request.Model, _modelId), + new(OpenTelemetryConsts.GenAI.System, _modelProvider), + ]); + + if (activity is not null) + { + if (_endpointAddress is not null) + { + _ = activity + .SetTag(OpenTelemetryConsts.Server.Address, _endpointAddress) + .SetTag(OpenTelemetryConsts.Server.Port, _endpointPort); + } + + if (_dimensions is int dimensions) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.EmbeddingDimensions, dimensions); + } + } + } + + return activity; + } + + /// Adds embedding generation response information to the activity. + private void SetCompletionResponse( + Activity? activity, + GeneratedEmbeddings? embeddings, + Exception? error, + Stopwatch? stopwatch) + { + if (!Enabled) + { + return; + } + + int? inputTokens = null; + string? responseModelId = null; + if (embeddings is not null) + { + responseModelId = embeddings.FirstOrDefault()?.ModelId; + if (embeddings.Usage?.InputTokenCount is int i) + { + inputTokens = inputTokens.GetValueOrDefault() + i; + } + } + + if (_operationDurationHistogram.Enabled && stopwatch is not null) + { + TagList tags = default; + AddMetricTags(ref tags, responseModelId); + if (error is not null) + { + tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); + } + + _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); + } + + if (_tokenUsageHistogram.Enabled && inputTokens.HasValue) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "input"); + AddMetricTags(ref tags, responseModelId); + + _tokenUsageHistogram.Record(inputTokens.Value); + } + + if (activity is null) + { + return; + } + + if (error is not null) + { + _ = activity + .SetTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + return; + } + + if (inputTokens.HasValue) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.InputTokens, inputTokens); + } + + if (responseModelId is not null) + { + _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.Model, responseModelId); + } + } + + private void AddMetricTags(ref TagList tags, string? responseModelId) + { + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, "embedding"); + + if (_modelId is string requestModel) + { + tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModel); + } + + tags.Add(OpenTelemetryConsts.GenAI.System, _modelProvider); + + if (_endpointAddress is string endpointAddress) + { + tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Port, _endpointPort); + } + + // Assume all of the embeddings in the same batch used the same model + if (responseModelId is not null) + { + tags.Add(OpenTelemetryConsts.GenAI.Response.Model, responseModelId); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..ba60847ef93 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs @@ -0,0 +1,34 @@ +// 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.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +public static class OpenTelemetryEmbeddingGeneratorBuilderExtensions +{ + /// + /// Adds OpenTelemetry support to the embedding generator pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// + /// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/. + /// The specification is still experimental and subject to change; as such, the telemetry output by this generator is also subject to change. + /// + /// The type of input used to produce embeddings. + /// The type of embedding generated. + /// The . + /// An optional source name that will be used on the telemetry data. + /// An optional callback that can be used to configure the instance. + /// The . + public static EmbeddingGeneratorBuilder UseOpenTelemetry( + this EmbeddingGeneratorBuilder builder, string? sourceName = null, Action>? configure = null) + where TEmbedding : Embedding => + Throw.IfNull(builder).Use(innerGenerator => + { + var generator = new OpenTelemetryEmbeddingGenerator(innerGenerator, sourceName); + configure?.Invoke(generator); + return generator; + }); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/EmptyServiceProvider.cs b/src/Libraries/Microsoft.Extensions.AI/EmptyServiceProvider.cs new file mode 100644 index 00000000000..5e3abc9fc0c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/EmptyServiceProvider.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.AI; + +/// Provides an implementation of that contains no services. +internal sealed class EmptyServiceProvider : IKeyedServiceProvider +{ + /// Gets a singleton instance of . + public static EmptyServiceProvider Instance { get; } = new(); + + /// + public object? GetService(Type serviceType) => null; + + /// + public object? GetKeyedService(Type serviceType, object? serviceKey) => null; + + /// + public object GetRequiredKeyedService(Type serviceType, object? serviceKey) => + GetKeyedService(serviceType, serviceKey) ?? + throw new InvalidOperationException($"No service for type '{serviceType}' and key '{serviceKey}' has been registered."); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionContext.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionContext.cs new file mode 100644 index 00000000000..25f239f8883 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionContext.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Threading; + +namespace Microsoft.Extensions.AI; + +/// Provides additional context to the invocation of an created by . +/// +/// A delegate or passed to methods may represent a method that has a parameter +/// of type . Whereas all other parameters are passed by name from the supplied collection of arguments, +/// a parameter is passed specially by the implementation, in order to pass relevant +/// context into the method's invocation. For example, any passed to the +/// method is available from the property. +/// +public class AIFunctionContext +{ + /// Initializes a new instance of the class. + public AIFunctionContext() + { + } + + /// Gets or sets a related to the operation. + public CancellationToken CancellationToken { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs new file mode 100644 index 00000000000..0fff0cd64fa --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -0,0 +1,480 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Collections; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides factory methods for creating commonly-used implementations of . +public static +#if NET + partial +#endif + class AIFunctionFactory +{ + internal const string UsesReflectionJsonSerializerMessage = + "This method uses the reflection-based JsonSerializer which can break in trimmed or AOT applications."; + + /// Lazily-initialized default options instance. + private static AIFunctionFactoryCreateOptions? _defaultOptions; + + /// Creates an instance for a method, specified via a delegate. + /// The method to be represented via the created . + /// The created for invoking . + [RequiresUnreferencedCode(UsesReflectionJsonSerializerMessage)] + [RequiresDynamicCode(UsesReflectionJsonSerializerMessage)] + public static AIFunction Create(Delegate method) => Create(method, _defaultOptions ??= new()); + + /// Creates an instance for a method, specified via a delegate. + /// The method to be represented via the created . + /// Metadata to use to override defaults inferred from . + /// The created for invoking . + public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions options) + { + _ = Throw.IfNull(method); + _ = Throw.IfNull(options); + return new ReflectionAIFunction(method.Method, method.Target, options); + } + + /// Creates an instance for a method, specified via a delegate. + /// The method to be represented via the created . + /// The name to use for the . + /// The description to use for the . + /// The created for invoking . + [RequiresUnreferencedCode("Reflection is used to access types from the supplied Delegate.")] + [RequiresDynamicCode("Reflection is used to access types from the supplied Delegate.")] + public static AIFunction Create(Delegate method, string? name, string? description = null) + => Create(method, (_defaultOptions ??= new()).SerializerOptions, name, description); + + /// Creates an instance for a method, specified via a delegate. + /// The method to be represented via the created . + /// The used to marshal function parameters. + /// The name to use for the . + /// The description to use for the . + /// The created for invoking . + public static AIFunction Create(Delegate method, JsonSerializerOptions options, string? name = null, string? description = null) + { + _ = Throw.IfNull(method); + _ = Throw.IfNull(options); + return new ReflectionAIFunction(method.Method, method.Target, new(options) { Name = name, Description = description }); + } + + /// + /// Creates an instance for a method, specified via an instance + /// and an optional target object if the method is an instance method. + /// + /// The method to be represented via the created . + /// + /// The target object for the if it represents an instance method. + /// This should be if and only if is a static method. + /// + /// The created for invoking . + [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] + [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] + public static AIFunction Create(MethodInfo method, object? target = null) + => Create(method, target, _defaultOptions ??= new()); + + /// + /// Creates an instance for a method, specified via an instance + /// and an optional target object if the method is an instance method. + /// + /// The method to be represented via the created . + /// + /// The target object for the if it represents an instance method. + /// This should be if and only if is a static method. + /// + /// Metadata to use to override defaults inferred from . + /// The created for invoking . + public static AIFunction Create(MethodInfo method, object? target, AIFunctionFactoryCreateOptions options) + { + _ = Throw.IfNull(method); + _ = Throw.IfNull(options); + return new ReflectionAIFunction(method, target, options); + } + + private sealed +#if NET + partial +#endif + class ReflectionAIFunction : AIFunction + { + private readonly MethodInfo _method; + private readonly object? _target; + private readonly Func, AIFunctionContext?, object?>[] _parameterMarshalers; + private readonly Func> _returnMarshaler; + private readonly JsonTypeInfo? _returnTypeInfo; + private readonly bool _needsAIFunctionContext; + + /// + /// Initializes a new instance of the class for a method, specified via an instance + /// and an optional target object if the method is an instance method. + /// + /// The method to be represented via the created . + /// + /// The target object for the if it represents an instance method. + /// This should be if and only if is a static method. + /// + /// Function creation options. + public ReflectionAIFunction(MethodInfo method, object? target, AIFunctionFactoryCreateOptions options) + { + _ = Throw.IfNull(method); + _ = Throw.IfNull(options); + + options.SerializerOptions.MakeReadOnly(); + + if (method.ContainsGenericParameters) + { + Throw.ArgumentException(nameof(method), "Open generic methods are not supported"); + } + + if (!method.IsStatic && target is null) + { + Throw.ArgumentNullException(nameof(target), "Target must not be null for an instance method."); + } + + _method = method; + _target = target; + + // Get the function name to use. + string? functionName = options.Name; + if (functionName is null) + { + functionName = SanitizeMetadataName(method.Name!); + + const string AsyncSuffix = "Async"; + if (IsAsyncMethod(method) && + functionName.EndsWith(AsyncSuffix, StringComparison.Ordinal) && + functionName.Length > AsyncSuffix.Length) + { + functionName = functionName.Substring(0, functionName.Length - AsyncSuffix.Length); + } + + static bool IsAsyncMethod(MethodInfo method) + { + Type t = method.ReturnType; + + if (t == typeof(Task) || t == typeof(ValueTask)) + { + return true; + } + + if (t.IsGenericType) + { + t = t.GetGenericTypeDefinition(); + if (t == typeof(Task<>) || t == typeof(ValueTask<>) || t == typeof(IAsyncEnumerable<>)) + { + return true; + } + } + + return false; + } + } + + // Build up a list of AIParameterMetadata for the parameters we expect to be populated + // from arguments. Some arguments are populated specially, not from arguments, and thus + // we don't want to advertise their metadata. + List? parameterMetadata = options.Parameters is not null ? null : []; + + // Get marshaling delegates for parameters and build up the parameter metadata. + var parameters = method.GetParameters(); + _parameterMarshalers = new Func, AIFunctionContext?, object?>[parameters.Length]; + bool sawAIContextParameter = false; + for (int i = 0; i < parameters.Length; i++) + { + if (GetParameterMarshaler(options.SerializerOptions, parameters[i], ref sawAIContextParameter, out _parameterMarshalers[i]) is AIFunctionParameterMetadata parameterView) + { + parameterMetadata?.Add(parameterView); + } + } + + _needsAIFunctionContext = sawAIContextParameter; + + // Get the return type and a marshaling func for the return value. + Type returnType = GetReturnMarshaler(method, out _returnMarshaler); + _returnTypeInfo = returnType != typeof(void) ? options.SerializerOptions.GetTypeInfo(returnType) : null; + + Metadata = new AIFunctionMetadata(functionName) + { + Description = options.Description ?? method.GetCustomAttribute(inherit: true)?.Description ?? string.Empty, + Parameters = options.Parameters ?? parameterMetadata!, + ReturnParameter = options.ReturnParameter ?? new() + { + ParameterType = returnType, + Description = method.ReturnParameter.GetCustomAttribute(inherit: true)?.Description, + Schema = FunctionCallHelpers.InferReturnParameterJsonSchema(returnType, options.SerializerOptions), + }, + AdditionalProperties = options.AdditionalProperties ?? EmptyReadOnlyDictionary.Instance, + JsonSerializerOptions = options.SerializerOptions, + }; + } + + /// + public override AIFunctionMetadata Metadata { get; } + + /// + protected override async Task InvokeCoreAsync( + IEnumerable>? arguments, + CancellationToken cancellationToken) + { + var paramMarshalers = _parameterMarshalers; + object?[] args = paramMarshalers.Length != 0 ? new object?[paramMarshalers.Length] : []; + + IReadOnlyDictionary argDict = + arguments is null || args.Length == 0 ? EmptyReadOnlyDictionary.Instance : + arguments as IReadOnlyDictionary ?? + arguments. +#if NET8_0_OR_GREATER + ToDictionary(); +#else + ToDictionary(kvp => kvp.Key, kvp => kvp.Value); +#endif + AIFunctionContext? context = _needsAIFunctionContext ? + new() { CancellationToken = cancellationToken } : + null; + + for (int i = 0; i < args.Length; i++) + { + args[i] = paramMarshalers[i](argDict, context); + } + + object? result = await _returnMarshaler(ReflectionInvoke(_method, _target, args)).ConfigureAwait(false); + + switch (_returnTypeInfo) + { + case null: + Debug.Assert(Metadata.ReturnParameter.ParameterType == typeof(void), "The return parameter is not void."); + return null; + + case { Kind: JsonTypeInfoKind.None }: + // Special-case trivial contracts to avoid the more expensive general-purpose serialization path. + return JsonSerializer.SerializeToElement(result, _returnTypeInfo); + + default: + { + // Serialize asynchronously to support potential IAsyncEnumerable responses. + using MemoryStream stream = new(); + await JsonSerializer.SerializeAsync(stream, result, _returnTypeInfo, cancellationToken).ConfigureAwait(false); + Utf8JsonReader reader = new(stream.GetBuffer().AsSpan(0, (int)stream.Length)); + return JsonElement.ParseValue(ref reader); + } + } + } + + /// + /// Gets a delegate for handling the marshaling of a parameter. + /// + private static AIFunctionParameterMetadata? GetParameterMarshaler( + JsonSerializerOptions options, + ParameterInfo parameter, + ref bool sawAIFunctionContext, + out Func, AIFunctionContext?, object?> marshaler) + { + if (string.IsNullOrWhiteSpace(parameter.Name)) + { + Throw.ArgumentException(nameof(parameter), "Parameter is missing a name."); + } + + // Special-case an AIFunctionContext parameter. + if (parameter.ParameterType == typeof(AIFunctionContext)) + { + if (sawAIFunctionContext) + { + Throw.ArgumentException(nameof(parameter), $"Only one {nameof(AIFunctionContext)} parameter is permitted."); + } + + sawAIFunctionContext = true; + + marshaler = static (_, ctx) => + { + Debug.Assert(ctx is not null, "Expected a non-null context object."); + return ctx; + }; + return null; + } + + // Resolve the contract used to marshall the value from JSON -- can throw if not supported or not found. + Type parameterType = parameter.ParameterType; + JsonTypeInfo typeInfo = options.GetTypeInfo(parameterType); + + // Create a marshaler that simply looks up the parameter by name in the arguments dictionary. + marshaler = (IReadOnlyDictionary arguments, AIFunctionContext? _) => + { + // If the parameter has an argument specified in the dictionary, return that argument. + if (arguments.TryGetValue(parameter.Name, out object? value)) + { + return value switch + { + null => null, // Return as-is if null -- if the parameter is a struct this will be handled by MethodInfo.Invoke + _ when parameterType.IsInstanceOfType(value) => value, // Do nothing if value is assignable to parameter type + JsonElement element => JsonSerializer.Deserialize(element, typeInfo), + JsonDocument doc => JsonSerializer.Deserialize(doc, typeInfo), + JsonNode node => JsonSerializer.Deserialize(node, typeInfo), + _ => MarshallViaJsonRoundtrip(value), + }; + + object? MarshallViaJsonRoundtrip(object value) + { +#pragma warning disable CA1031 // Do not catch general exception types + try + { + string json = JsonSerializer.Serialize(value, options.GetTypeInfo(value.GetType())); + return JsonSerializer.Deserialize(json, typeInfo); + } + catch + { + // Eat any exceptions and fall back to the original value to force a cast exception later on. + return value; + } +#pragma warning restore CA1031 // Do not catch general exception types + } + } + + // There was no argument for the parameter. Try to use a default value. + if (parameter.HasDefaultValue) + { + return parameter.DefaultValue; + } + + // No default either. Leave it empty. + return null; + }; + + string? description = parameter.GetCustomAttribute(inherit: true)?.Description; + return new AIFunctionParameterMetadata(parameter.Name) + { + Description = description, + HasDefaultValue = parameter.HasDefaultValue, + DefaultValue = parameter.HasDefaultValue ? parameter.DefaultValue : null, + IsRequired = !parameter.IsOptional, + ParameterType = parameter.ParameterType, + Schema = FunctionCallHelpers.InferParameterJsonSchema( + parameter.ParameterType, + parameter.Name, + description, + parameter.HasDefaultValue, + parameter.DefaultValue, + options) + }; + } + + /// + /// Gets a delegate for handling the result value of a method, converting it into the to return from the invocation. + /// + private static Type GetReturnMarshaler(MethodInfo method, out Func> marshaler) + { + // Handle each known return type for the method + Type returnType = method.ReturnType; + + // Task + if (returnType == typeof(Task)) + { + marshaler = async static result => + { + await ((Task)ThrowIfNullResult(result)).ConfigureAwait(false); + return null; + }; + return typeof(void); + } + + // ValueTask + if (returnType == typeof(ValueTask)) + { + marshaler = async static result => + { + await ((ValueTask)ThrowIfNullResult(result)).ConfigureAwait(false); + return null; + }; + return typeof(void); + } + + if (returnType.IsGenericType) + { + // Task + if (returnType.GetGenericTypeDefinition() == typeof(Task<>) && + returnType.GetProperty(nameof(Task.Result), BindingFlags.Public | BindingFlags.Instance)?.GetGetMethod() is MethodInfo taskResultGetter) + { + marshaler = async result => + { + await ((Task)ThrowIfNullResult(result)).ConfigureAwait(false); + return ReflectionInvoke(taskResultGetter, result, null); + }; + return taskResultGetter.ReturnType; + } + + // ValueTask + if (returnType.GetGenericTypeDefinition() == typeof(ValueTask<>) && + returnType.GetMethod(nameof(ValueTask.AsTask), BindingFlags.Public | BindingFlags.Instance) is MethodInfo valueTaskAsTask && + valueTaskAsTask.ReturnType.GetProperty(nameof(ValueTask.Result), BindingFlags.Public | BindingFlags.Instance)?.GetGetMethod() is MethodInfo asTaskResultGetter) + { + marshaler = async result => + { + var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(result), null)!; + await task.ConfigureAwait(false); + return ReflectionInvoke(asTaskResultGetter, task, null); + }; + return asTaskResultGetter.ReturnType; + } + } + + // For everything else, just use the result as-is. + marshaler = result => new ValueTask(result); + return returnType; + + // Throws an exception if a result is found to be null unexpectedly + static object ThrowIfNullResult(object? result) => result ?? throw new InvalidOperationException("Function returned null unexpectedly."); + } + + /// Invokes the MethodInfo with the specified target object and arguments. + private static object? ReflectionInvoke(MethodInfo method, object? target, object?[]? arguments) + { +#if NET + return method.Invoke(target, BindingFlags.DoNotWrapExceptions, binder: null, arguments, culture: null); +#else + try + { + return method.Invoke(target, BindingFlags.Default, binder: null, arguments, culture: null); + } + catch (TargetInvocationException e) when (e.InnerException is not null) + { + // If we're targeting .NET Framework, such that BindingFlags.DoNotWrapExceptions + // is ignored, the original exception will be wrapped in a TargetInvocationException. + // Unwrap it and throw that original exception, maintaining its stack information. + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw(); + return null; + } +#endif + } + + /// + /// Remove characters from method name that are valid in metadata but shouldn't be used in a method name. + /// This is primarily intended to remove characters emitted by for compiler-generated method name mangling. + /// + private static string SanitizeMetadataName(string methodName) => + InvalidNameCharsRegex().Replace(methodName, "_"); + + /// Regex that flags any character other than ASCII digits or letters or the underscore. +#if NET + [GeneratedRegex("[^0-9A-Za-z_]")] + private static partial Regex InvalidNameCharsRegex(); +#else + private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; + private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); +#endif + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs new file mode 100644 index 00000000000..8e0db9b4813 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Options that can be provided when creating an from a method. +/// +public sealed class AIFunctionFactoryCreateOptions +{ + /// + /// Initializes a new instance of the class with default serializer options. + /// + [RequiresUnreferencedCode(AIFunctionFactory.UsesReflectionJsonSerializerMessage)] + [RequiresDynamicCode(AIFunctionFactory.UsesReflectionJsonSerializerMessage)] + public AIFunctionFactoryCreateOptions() + : this(JsonSerializerOptions.Default) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The JSON serialization options used to marshal .NET types. + public AIFunctionFactoryCreateOptions(JsonSerializerOptions serializerOptions) + { + SerializerOptions = Throw.IfNull(serializerOptions); + } + + /// Gets the used to marshal .NET values being passed to the underlying delegate. + public JsonSerializerOptions SerializerOptions { get; } + + /// Gets or sets the name to use for the function. + /// + /// If , it will default to one derived from the method represented by the passed or . + /// + public string? Name { get; set; } + + /// Gets or sets the description to use for the function. + /// + /// If , it will default to one derived from the passed or , if possible + /// (e.g. via a on the method). + /// + public string? Description { get; set; } + + /// Gets or sets metadata for the parameters of the function. + /// + /// If , it will default to metadata derived from the passed or . + /// + public IReadOnlyList? Parameters { get; set; } + + /// Gets or sets metadata for function's return parameter. + /// + /// If , it will default to one derived from the passed or . + /// + public AIFunctionReturnParameterMetadata? ReturnParameter { get; set; } + + /// + /// Gets or sets additional values that will be stored on the resulting property. + /// + /// + /// This can be used to provide arbitrary information about the function. + /// + public IReadOnlyDictionary? AdditionalProperties { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs b/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs new file mode 100644 index 00000000000..71edc9404b6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs @@ -0,0 +1,78 @@ +// 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 System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.Extensions.AI; + +/// Provides cached options around JSON serialization to be used by the project. +internal static partial class JsonDefaults +{ + /// Gets the singleton to use for serialization-related operations. + public static JsonSerializerOptions Options { get; } = CreateDefaultOptions(); + + /// Creates the default to use for serialization-related operations. + private static JsonSerializerOptions CreateDefaultOptions() + { + // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, + // and we want to be flexible in terms of what can be put into the various collections in the object model. + // Otherwise, use the source-generated options to enable Native AOT. + + if (JsonSerializer.IsReflectionEnabledByDefault) + { + // Keep in sync with the JsonSourceGenerationOptions on JsonContext below. + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, +#pragma warning disable IL3050 + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), +#pragma warning restore IL3050 + }; + + options.MakeReadOnly(); + return options; + } + else + { + return JsonContext.Default.Options; + } + } + + // Keep in sync with CreateDefaultOptions above. + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + [JsonSerializable(typeof(IList))] + [JsonSerializable(typeof(ChatOptions))] + [JsonSerializable(typeof(EmbeddingGenerationOptions))] + [JsonSerializable(typeof(ChatClientMetadata))] + [JsonSerializable(typeof(EmbeddingGeneratorMetadata))] + [JsonSerializable(typeof(ChatCompletion))] + [JsonSerializable(typeof(StreamingChatCompletionUpdate))] + [JsonSerializable(typeof(IReadOnlyList))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(IEnumerable))] + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(long))] + [JsonSerializable(typeof(float))] + [JsonSerializable(typeof(double))] + [JsonSerializable(typeof(bool))] + [JsonSerializable(typeof(TimeSpan))] + [JsonSerializable(typeof(DateTimeOffset))] + [JsonSerializable(typeof(Embedding))] + [JsonSerializable(typeof(Embedding))] + [JsonSerializable(typeof(Embedding))] +#if NET + [JsonSerializable(typeof(Embedding))] +#endif + [JsonSerializable(typeof(Embedding))] + [JsonSerializable(typeof(Embedding))] + [JsonSerializable(typeof(AIContent))] + private sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj new file mode 100644 index 00000000000..8e389b61652 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -0,0 +1,42 @@ + + + + Microsoft.Extensions.AI + Utilities for working with generative AI components. + AI + + + + preview + 0 + 0 + + + + $(TargetFrameworks);netstandard2.0 + $(NoWarn);CA2227;CA1034;SA1316;S1067;S1121;S1994;S3253 + true + + + + true + true + + + + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs new file mode 100644 index 00000000000..31e61101a13 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs @@ -0,0 +1,90 @@ +// 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 S3218 // Inner class members should not shadow outer class "static" or type members +#pragma warning disable CA1716 // Identifiers should not match keywords +#pragma warning disable S4041 // Type names should not match namespaces + +/// Provides constants used by various telemetry services. +internal static class OpenTelemetryConsts +{ + public const string DefaultSourceName = "Experimental.Microsoft.Extensions.AI"; + + public const string SecondsUnit = "s"; + public const string TokensUnit = "token"; + + public static class Error + { + public const string Type = "error.type"; + } + + public static class GenAI + { + public const string Completion = "gen_ai.completion"; + public const string Prompt = "gen_ai.prompt"; + public const string System = "gen_ai.system"; + + public static class Client + { + public static class OperationDuration + { + public const string Description = "Measures the duration of a GenAI operation"; + public const string Name = "gen_ai.client.operation.duration"; + public static readonly double[] ExplicitBucketBoundaries = [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92]; + } + + public static class TokenUsage + { + public const string Description = "Measures number of input and output tokens used"; + public const string Name = "gen_ai.client.token.usage"; + public static readonly int[] ExplicitBucketBoundaries = [1, 4, 16, 64, 256, 1_024, 4_096, 16_384, 65_536, 262_144, 1_048_576, 4_194_304, 16_777_216, 67_108_864]; + } + } + + public static class Content + { + public const string Completion = "gen_ai.content.completion"; + public const string Prompt = "gen_ai.content.prompt"; + } + + public static class Operation + { + public const string Name = "gen_ai.operation.name"; + } + + public static class Request + { + public const string EmbeddingDimensions = "gen_ai.request.embedding.dimensions"; + public const string FrequencyPenalty = "gen_ai.request.frequency_penalty"; + public const string Model = "gen_ai.request.model"; + public const string MaxTokens = "gen_ai.request.max_tokens"; + public const string PresencePenalty = "gen_ai.request.presence_penalty"; + public const string StopSequences = "gen_ai.request.stop_sequences"; + public const string Temperature = "gen_ai.request.temperature"; + public const string TopK = "gen_ai.request.top_k"; + public const string TopP = "gen_ai.request.top_p"; + } + + public static class Response + { + public const string FinishReasons = "gen_ai.response.finish_reasons"; + public const string Id = "gen_ai.response.id"; + public const string InputTokens = "gen_ai.response.input_tokens"; + public const string Model = "gen_ai.response.model"; + public const string OutputTokens = "gen_ai.response.output_tokens"; + } + + public static class Token + { + public const string Type = "gen_ai.token.type"; + } + } + + public static class Server + { + public const string Address = "server.address"; + public const string Port = "server.port"; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/README.md b/src/Libraries/Microsoft.Extensions.AI/README.md new file mode 100644 index 00000000000..ef092749200 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/README.md @@ -0,0 +1,27 @@ +# Microsoft.Extensions.AI + +Provides utilities for working with generative AI components. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.AI +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +Please refer to the [README](https://www.nuget.org/packages/Microsoft.Extensions.AI.Abstractions/#readme-body-tab) for the [Microsoft.Extensions.AI.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.AI.Abstractions) package. + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Shared/CollectionExtensions/CollectionExtensions.cs b/src/Shared/CollectionExtensions/CollectionExtensions.cs new file mode 100644 index 00000000000..33196e6e771 --- /dev/null +++ b/src/Shared/CollectionExtensions/CollectionExtensions.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +#pragma warning disable S108 // Nested blocks of code should not be left empty +#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable SA1501 // Statement should not be on a single line + +#pragma warning disable CA1716 +namespace Microsoft.Shared.Collections; +#pragma warning restore CA1716 + +/// +/// Utilities to augment the basic collection types. +/// +#if !SHARED_PROJECT +[ExcludeFromCodeCoverage] +#endif + +internal static class CollectionExtensions +{ + /// Attempts to extract a typed value from the dictionary. + /// The dictionary to query. + /// The key to locate. + /// The value retrieved from the dictionary, if found; otherwise, default. + /// True if the value was found and converted to the requested type; otherwise, false. + /// + /// If a value is found for the key in the dictionary, but the value is not of the requested type but is + /// an object, the method will attempt to convert the object to the requested type. + /// is employed because these methods are primarily intended for use with primitives. + /// + public static bool TryGetConvertedValue(this IReadOnlyDictionary? input, string key, [NotNullWhen(true)] out T? value) + { + object? valueObject = null; + _ = input?.TryGetValue(key, out valueObject); + return TryConvertValue(valueObject, out value); + } + + private static bool TryConvertValue(object? obj, [NotNullWhen(true)] out T? value) + { + switch (obj) + { + case T t: + // The object is already of the requested type. Return it. + value = t; + return true; + + case IConvertible: + // The object is convertible; try to convert it to the requested type. Unfortunately, there's no + // convenient way to do this that avoids exceptions and that doesn't involve a ton of boilerplate, + // so we only try when the source object is at least an IConvertible, which is what ChangeType uses. + try + { + value = (T)Convert.ChangeType(obj, typeof(T), CultureInfo.InvariantCulture); + return true; + } + catch (ArgumentException) { } + catch (InvalidCastException) { } + catch (FormatException) { } + catch (OverflowException) { } + break; + } + + // Unable to convert the object to the requested type. Fail. + value = default; + return false; + } +} diff --git a/src/Shared/CollectionExtensions/README.md b/src/Shared/CollectionExtensions/README.md new file mode 100644 index 00000000000..a732b7c36d4 --- /dev/null +++ b/src/Shared/CollectionExtensions/README.md @@ -0,0 +1,11 @@ +# Collection Extensions + +`TryGetTypedValue` performs a ``TryGetValue` on a dictionary and then attempts to cast the value to the specified type. If the value is not of the specified type, false is returned. + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/src/Shared/NumericExtensions/README.md b/src/Shared/NumericExtensions/README.md index bcb2d9a7cba..c93835acd3b 100644 --- a/src/Shared/NumericExtensions/README.md +++ b/src/Shared/NumericExtensions/README.md @@ -6,6 +6,6 @@ To use this in your project, add the following to your `.csproj` file: ```xml - true + true ``` diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs new file mode 100644 index 00000000000..e71b2f431e8 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class AdditionalPropertiesDictionaryTests +{ + [Fact] + public void Constructor_Roundtrips() + { + AdditionalPropertiesDictionary d = new(); + Assert.Empty(d); + + d = new(new Dictionary { ["key1"] = "value1" }); + Assert.Single(d); + + d = new((IEnumerable>)new Dictionary { ["key1"] = "value1", ["key2"] = "value2" }); + Assert.Equal(2, d.Count); + } + + [Fact] + public void Comparer_OrdinalIgnoreCase() + { + AdditionalPropertiesDictionary d = new() + { + ["key1"] = "value1", + ["KEY1"] = "value2", + ["key2"] = "value3", + ["key3"] = "value4", + ["KeY3"] = "value5", + }; + + Assert.Equal(3, d.Count); + + Assert.Equal("value2", d["key1"]); + Assert.Equal("value2", d["kEY1"]); + + Assert.Equal("value3", d["key2"]); + Assert.Equal("value3", d["KEY2"]); + + Assert.Equal("value5", d["Key3"]); + Assert.Equal("value5", d["KEy3"]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs new file mode 100644 index 00000000000..2c54a6f0865 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.Extensions.AI; + +internal static class AssertExtensions +{ + /// + /// Asserts that the two function call parameters are equal, up to JSON equivalence. + /// + public static void EqualFunctionCallParameters( + IDictionary? expected, + IDictionary? actual, + JsonSerializerOptions? options = null) + { + if (expected is null || actual is null) + { + Assert.Equal(expected, actual); + return; + } + + foreach (var expectedEntry in expected) + { + if (!actual.TryGetValue(expectedEntry.Key, out object? actualValue)) + { + throw new XunitException($"Expected parameter '{expectedEntry.Key}' not found in actual value."); + } + + AreJsonEquivalentValues(expectedEntry.Value, actualValue, options, propertyName: expectedEntry.Key); + } + + if (expected.Count != actual.Count) + { + var extraParameters = actual + .Where(e => !expected.ContainsKey(e.Key)) + .Select(e => $"'{e.Key}'") + .First(); + + throw new XunitException($"Actual value contains additional parameters {string.Join(", ", extraParameters)} not found in expected value."); + } + } + + /// + /// Asserts that the two function call results are equal, up to JSON equivalence. + /// + 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) + { + options ??= JsonSerializerOptions.Default; + JsonElement expectedElement = NormalizeToElement(expected, options); + JsonElement actualElement = NormalizeToElement(actual, options); + if (!JsonElement.DeepEquals(expectedElement, actualElement)) + { + string message = propertyName is null + ? $"Function result does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}" + : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}"; + + throw new XunitException(message); + } + + 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/CapturingLogger.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/CapturingLogger.cs new file mode 100644 index 00000000000..274021988e1 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/CapturingLogger.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +#pragma warning disable SA1402 // File may only contain a single type + +namespace Microsoft.Extensions.AI; + +internal sealed class CapturingLogger : ILogger +{ + private readonly Stack _scopes = new(); + private readonly List _entries = []; + private readonly LogLevel _enabledLevel; + + public CapturingLogger(LogLevel enabledLevel = LogLevel.Trace) + { + _enabledLevel = enabledLevel; + } + + public IReadOnlyList Entries => _entries; + + public IDisposable? BeginScope(TState state) + where TState : notnull + { + var scope = new LoggerScope(this); + _scopes.Push(scope); + return scope; + } + + public bool IsEnabled(LogLevel logLevel) => logLevel >= _enabledLevel; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + lock (_entries) + { + _entries.Add(new LogEntry(logLevel, eventId, state, exception, message)); + } + } + + private sealed class LoggerScope(CapturingLogger owner) : IDisposable + { + public void Dispose() => owner.EndScope(this); + } + + private void EndScope(LoggerScope loggerScope) + { + if (_scopes.Peek() != loggerScope) + { + throw new InvalidOperationException("Logger scopes out of order"); + } + + _scopes.Pop(); + } + + public record LogEntry(LogLevel Level, EventId EventId, object? State, Exception? Exception, string Message); +} + +internal sealed class CapturingLoggerProvider : ILoggerProvider +{ + public CapturingLogger Logger { get; } = new(); + + public ILogger CreateLogger(string categoryName) => Logger; + + void IDisposable.Dispose() + { + // nop + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs new file mode 100644 index 00000000000..68f5ad12245 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ChatClientExtensionsTests +{ + [Fact] + public void CompleteAsync_InvalidArgs_Throws() + { + Assert.Throws("client", () => + { + _ = ChatClientExtensions.CompleteAsync(null!, "hello"); + }); + + Assert.Throws("chatMessage", () => + { + _ = ChatClientExtensions.CompleteAsync(new TestChatClient(), null!); + }); + } + + [Fact] + public void CompleteStreamingAsync_InvalidArgs_Throws() + { + Assert.Throws("client", () => + { + _ = ChatClientExtensions.CompleteStreamingAsync(null!, "hello"); + }); + + Assert.Throws("chatMessage", () => + { + _ = ChatClientExtensions.CompleteStreamingAsync(new TestChatClient(), null!); + }); + } + + [Fact] + public async Task CompleteAsync_CreatesTextMessageAsync() + { + var expectedResponse = new ChatCompletion([new ChatMessage()]); + var expectedOptions = new ChatOptions(); + using var cts = new CancellationTokenSource(); + + using TestChatClient client = new() + { + CompleteAsyncCallback = (chatMessages, options, cancellationToken) => + { + ChatMessage m = Assert.Single(chatMessages); + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("hello", m.Text); + + Assert.Same(expectedOptions, options); + + Assert.Equal(cts.Token, cancellationToken); + + return Task.FromResult(expectedResponse); + }, + }; + + ChatCompletion response = await client.CompleteAsync("hello", expectedOptions, cts.Token); + + Assert.Same(expectedResponse, response); + } + + [Fact] + public async Task CompleteStreamingAsync_CreatesTextMessageAsync() + { + var expectedOptions = new ChatOptions(); + using var cts = new CancellationTokenSource(); + + using TestChatClient client = new() + { + CompleteStreamingAsyncCallback = (chatMessages, options, cancellationToken) => + { + ChatMessage m = Assert.Single(chatMessages); + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("hello", m.Text); + + Assert.Same(expectedOptions, options); + + Assert.Equal(cts.Token, cancellationToken); + + return YieldAsync([new StreamingChatCompletionUpdate { Text = "world" }]); + }, + }; + + int count = 0; + await foreach (var update in client.CompleteStreamingAsync("hello", expectedOptions, cts.Token)) + { + Assert.Equal(0, count); + Assert.Equal("world", update.Text); + count++; + } + + Assert.Equal(1, count); + } + + private static async IAsyncEnumerable YieldAsync(params StreamingChatCompletionUpdate[] updates) + { + await Task.Yield(); + foreach (var update in updates) + { + yield return update; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientMetadataTests.cs new file mode 100644 index 00000000000..43e24e61f8e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientMetadataTests.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ChatClientMetadataTests +{ + [Fact] + public void Constructor_NullValues_AllowedAndRoundtrip() + { + ChatClientMetadata metadata = new(null, null, null); + Assert.Null(metadata.ProviderName); + Assert.Null(metadata.ProviderUri); + Assert.Null(metadata.ModelId); + } + + [Fact] + public void Constructor_Value_Roundtrips() + { + var uri = new Uri("https://example.com"); + ChatClientMetadata metadata = new("providerName", uri, "theModel"); + Assert.Equal("providerName", metadata.ProviderName); + Assert.Same(uri, metadata.ProviderUri); + Assert.Equal("theModel", metadata.ModelId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs new file mode 100644 index 00000000000..a695e686f6e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs @@ -0,0 +1,170 @@ +// 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 ChatCompletionTests +{ + [Fact] + public void Constructor_InvalidArgs_Throws() + { + Assert.Throws("message", () => new ChatCompletion((ChatMessage)null!)); + Assert.Throws("choices", () => new ChatCompletion((IList)null!)); + } + + [Fact] + public void Constructor_Message_Roundtrips() + { + ChatMessage message = new(); + + ChatCompletion completion = new(message); + Assert.Same(message, completion.Message); + Assert.Same(message, Assert.Single(completion.Choices)); + } + + [Fact] + public void Constructor_Choices_Roundtrips() + { + List messages = + [ + new ChatMessage(), + new ChatMessage(), + new ChatMessage(), + ]; + + ChatCompletion completion = new(messages); + Assert.Same(messages, completion.Choices); + Assert.Equal(3, messages.Count); + } + + [Fact] + public void Message_EmptyChoices_Throws() + { + ChatCompletion completion = new([]); + + Assert.Empty(completion.Choices); + Assert.Throws(() => completion.Message); + } + + [Fact] + public void Message_SingleChoice_Returned() + { + ChatMessage message = new(); + ChatCompletion completion = new([message]); + + Assert.Same(message, completion.Message); + Assert.Same(message, completion.Choices[0]); + } + + [Fact] + public void Message_MultipleChoices_ReturnsFirst() + { + ChatMessage first = new(); + ChatCompletion completion = new([ + first, + new ChatMessage(), + ]); + + Assert.Same(first, completion.Message); + Assert.Same(first, completion.Choices[0]); + } + + [Fact] + public void Choices_SetNull_Throws() + { + ChatCompletion completion = new([]); + Assert.Throws("value", () => completion.Choices = null!); + } + + [Fact] + public void Properties_Roundtrip() + { + ChatCompletion completion = new([]); + + Assert.Null(completion.CompletionId); + completion.CompletionId = "id"; + Assert.Equal("id", completion.CompletionId); + + Assert.Null(completion.ModelId); + completion.ModelId = "modelId"; + Assert.Equal("modelId", completion.ModelId); + + Assert.Null(completion.CreatedAt); + completion.CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero); + Assert.Equal(new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), completion.CreatedAt); + + Assert.Null(completion.FinishReason); + completion.FinishReason = ChatFinishReason.ContentFilter; + Assert.Equal(ChatFinishReason.ContentFilter, completion.FinishReason); + + Assert.Null(completion.Usage); + UsageDetails usage = new(); + completion.Usage = usage; + Assert.Same(usage, completion.Usage); + + Assert.Null(completion.RawRepresentation); + object raw = new(); + completion.RawRepresentation = raw; + Assert.Same(raw, completion.RawRepresentation); + + Assert.Null(completion.AdditionalProperties); + AdditionalPropertiesDictionary additionalProps = []; + completion.AdditionalProperties = additionalProps; + Assert.Same(additionalProps, completion.AdditionalProperties); + + List newChoices = [new ChatMessage(), new ChatMessage()]; + completion.Choices = newChoices; + Assert.Same(newChoices, completion.Choices); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + ChatCompletion original = new( + [ + new ChatMessage(ChatRole.Assistant, "Choice1"), + new ChatMessage(ChatRole.Assistant, "Choice2"), + new ChatMessage(ChatRole.Assistant, "Choice3"), + new ChatMessage(ChatRole.Assistant, "Choice4"), + ]) + { + CompletionId = "id", + ModelId = "modelId", + CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), + FinishReason = ChatFinishReason.ContentFilter, + Usage = new UsageDetails(), + RawRepresentation = new(), + AdditionalProperties = new() { ["key"] = "value" }, + }; + + string json = JsonSerializer.Serialize(original, TestJsonSerializerContext.Default.ChatCompletion); + + ChatCompletion? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatCompletion); + + Assert.NotNull(result); + Assert.Equal(4, result.Choices.Count); + + for (int i = 0; i < original.Choices.Count; i++) + { + Assert.Equal(ChatRole.Assistant, result.Choices[i].Role); + Assert.Equal($"Choice{i + 1}", result.Choices[i].Text); + } + + Assert.Equal("id", result.CompletionId); + Assert.Equal("modelId", result.ModelId); + Assert.Equal(new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), result.CreatedAt); + Assert.Equal(ChatFinishReason.ContentFilter, result.FinishReason); + Assert.NotNull(result.Usage); + + Assert.NotNull(result.AdditionalProperties); + Assert.Single(result.AdditionalProperties); + Assert.True(result.AdditionalProperties.TryGetValue("key", out object? value)); + Assert.IsType(value); + Assert.Equal("value", ((JsonElement)value!).GetString()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatFinishReasonTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatFinishReasonTests.cs new file mode 100644 index 00000000000..0318a77b47b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatFinishReasonTests.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ChatFinishReasonTests +{ + [Fact] + public void Constructor_Value_Roundtrips() + { + Assert.Equal("abc", new ChatFinishReason("abc").Value); + } + + [Fact] + public void Constructor_NullOrWhiteSpace_Throws() + { + Assert.Throws(() => new ChatFinishReason(null!)); + Assert.Throws(() => new ChatFinishReason(" ")); + } + + [Fact] + public void Equality_UsesOrdinalIgnoreCaseComparison() + { + Assert.True(new ChatFinishReason("abc").Equals(new ChatFinishReason("ABC"))); + Assert.True(new ChatFinishReason("abc").Equals((object)new ChatFinishReason("ABC"))); + Assert.True(new ChatFinishReason("abc") == new ChatFinishReason("ABC")); + Assert.Equal(new ChatFinishReason("abc").GetHashCode(), new ChatFinishReason("ABC").GetHashCode()); + Assert.False(new ChatFinishReason("abc") != new ChatFinishReason("ABC")); + + Assert.False(new ChatFinishReason("abc").Equals(new ChatFinishReason("def"))); + Assert.False(new ChatFinishReason("abc").Equals((object)new ChatFinishReason("def"))); + Assert.False(new ChatFinishReason("abc").Equals(null)); + Assert.False(new ChatFinishReason("abc").Equals("abc")); + Assert.False(new ChatFinishReason("abc") == new ChatFinishReason("def")); + Assert.True(new ChatFinishReason("abc") != new ChatFinishReason("def")); + Assert.NotEqual(new ChatFinishReason("abc").GetHashCode(), new ChatFinishReason("def").GetHashCode()); // not guaranteed due to possible hash code collisions + } + + [Fact] + public void Singletons_UseKnownValues() + { + Assert.Equal("stop", ChatFinishReason.Stop.Value); + Assert.Equal("length", ChatFinishReason.Length.Value); + Assert.Equal("tool_calls", ChatFinishReason.ToolCalls.Value); + Assert.Equal("content_filter", ChatFinishReason.ContentFilter.Value); + } + + [Fact] + public void Value_NormalizesToStopped() + { + Assert.Equal("test", new ChatFinishReason("test").Value); + Assert.Equal("test", new ChatFinishReason("test").ToString()); + + Assert.Equal("TEST", new ChatFinishReason("TEST").Value); + Assert.Equal("TEST", new ChatFinishReason("TEST").ToString()); + + Assert.Equal("stop", default(ChatFinishReason).Value); + Assert.Equal("stop", default(ChatFinishReason).ToString()); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + ChatFinishReason role = new("abc"); + string? json = JsonSerializer.Serialize(role, TestJsonSerializerContext.Default.ChatFinishReason); + Assert.Equal("\"abc\"", json); + + ChatFinishReason? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatFinishReason); + Assert.Equal(role, result); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs new file mode 100644 index 00000000000..dbef5f4088b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs @@ -0,0 +1,382 @@ +// 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 ChatMessageTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + ChatMessage message = new(); + Assert.Null(message.AuthorName); + Assert.Empty(message.Contents); + Assert.Equal(ChatRole.User, message.Role); + Assert.Null(message.Text); + Assert.NotNull(message.Contents); + Assert.Same(message.Contents, message.Contents); + Assert.Empty(message.Contents); + Assert.Null(message.RawRepresentation); + Assert.Null(message.AdditionalProperties); + Assert.Equal(string.Empty, message.ToString()); + } + + [Theory] + [InlineData(null)] + [InlineData("text")] + public void Constructor_RoleString_PropsRoundtrip(string? text) + { + ChatMessage message = new(ChatRole.Assistant, text); + + Assert.Equal(ChatRole.Assistant, message.Role); + + Assert.Same(message.Contents, message.Contents); + if (text is null) + { + Assert.Empty(message.Contents); + } + else + { + Assert.Single(message.Contents); + TextContent tc = Assert.IsType(message.Contents[0]); + Assert.Equal(text, tc.Text); + } + + Assert.Null(message.AuthorName); + Assert.Null(message.RawRepresentation); + Assert.Null(message.AdditionalProperties); + Assert.Equal(text ?? string.Empty, message.ToString()); + } + + [Fact] + public void Constructor_RoleList_InvalidArgs_Throws() + { + Assert.Throws("contents", () => new ChatMessage(ChatRole.User, (IList)null!)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Constructor_RoleList_PropsRoundtrip(int messageCount) + { + List content = []; + for (int i = 0; i < messageCount; i++) + { + content.Add(new TextContent($"text-{i}")); + } + + ChatMessage message = new(ChatRole.System, content); + + Assert.Equal(ChatRole.System, message.Role); + + Assert.Same(message.Contents, message.Contents); + if (messageCount == 0) + { + Assert.Empty(message.Contents); + Assert.Null(message.Text); + } + else + { + Assert.Equal(messageCount, message.Contents.Count); + for (int i = 0; i < messageCount; i++) + { + TextContent tc = Assert.IsType(message.Contents[i]); + Assert.Equal($"text-{i}", tc.Text); + } + + Assert.Equal("text-0", message.Text); + Assert.Equal("text-0", message.ToString()); + } + + Assert.Null(message.AuthorName); + Assert.Null(message.RawRepresentation); + Assert.Null(message.AdditionalProperties); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \r\n\t\v ")] + public void AuthorName_InvalidArg_UsesNull(string? authorName) + { + ChatMessage message = new() + { + AuthorName = authorName + }; + Assert.Null(message.AuthorName); + + message.AuthorName = "author"; + Assert.Equal("author", message.AuthorName); + + message.AuthorName = authorName; + Assert.Null(message.AuthorName); + } + + [Fact] + public void Text_GetSet_UsesFirstTextContent() + { + ChatMessage message = new(ChatRole.User, + [ + new AudioContent("http://localhost/audio"), + new ImageContent("http://localhost/image"), + new FunctionCallContent("callId1", "fc1"), + new TextContent("text-1"), + new TextContent("text-2"), + new FunctionResultContent(new FunctionCallContent("callId1", "fc2"), "result"), + ]); + + TextContent textContent = Assert.IsType(message.Contents[3]); + Assert.Equal("text-1", textContent.Text); + Assert.Equal("text-1", message.Text); + Assert.Equal("text-1", message.ToString()); + + message.Text = "text-3"; + Assert.Equal("text-3", message.Text); + Assert.Equal("text-3", message.Text); + Assert.Same(textContent, message.Contents[3]); + Assert.Equal("text-3", message.ToString()); + } + + [Fact] + public void Text_Set_AddsTextMessageToEmptyList() + { + ChatMessage message = new(ChatRole.User, []); + Assert.Empty(message.Contents); + + message.Text = "text-1"; + Assert.Equal("text-1", message.Text); + + Assert.Single(message.Contents); + TextContent textContent = Assert.IsType(message.Contents[0]); + Assert.Equal("text-1", textContent.Text); + } + + [Fact] + public void Text_Set_AddsTextMessageToListWithNoText() + { + ChatMessage message = new(ChatRole.User, + [ + new AudioContent("http://localhost/audio"), + new ImageContent("http://localhost/image"), + new FunctionCallContent("callId1", "fc1"), + ]); + Assert.Equal(3, message.Contents.Count); + + message.Text = "text-1"; + Assert.Equal("text-1", message.Text); + Assert.Equal(4, message.Contents.Count); + + message.Text = "text-2"; + Assert.Equal("text-2", message.Text); + Assert.Equal(4, message.Contents.Count); + + message.Contents.RemoveAt(3); + Assert.Equal(3, message.Contents.Count); + + message.Text = "text-3"; + Assert.Equal("text-3", message.Text); + Assert.Equal(4, message.Contents.Count); + } + + [Fact] + public void Contents_InitializesToList() + { + // This is an implementation detail, but if this test starts failing, we need to ensure + // tests are in place for whatever possibly-custom implementation of IList is being used. + Assert.IsType>(new ChatMessage().Contents); + } + + [Fact] + public void Contents_Roundtrips() + { + ChatMessage message = new(); + Assert.Empty(message.Contents); + + List contents = []; + message.Contents = contents; + + Assert.Same(contents, message.Contents); + + message.Contents = contents; + Assert.Same(contents, message.Contents); + + message.Contents = null; + Assert.NotNull(message.Contents); + Assert.NotSame(contents, message.Contents); + Assert.Empty(message.Contents); + } + + [Fact] + public void RawRepresentation_Roundtrips() + { + ChatMessage message = new(); + Assert.Null(message.RawRepresentation); + + object raw = new(); + + message.RawRepresentation = raw; + Assert.Same(raw, message.RawRepresentation); + + message.RawRepresentation = raw; + Assert.Same(raw, message.RawRepresentation); + + message.RawRepresentation = null; + Assert.Null(message.RawRepresentation); + + message.RawRepresentation = raw; + Assert.Same(raw, message.RawRepresentation); + } + + [Fact] + public void AdditionalProperties_Roundtrips() + { + ChatMessage message = new(); + Assert.Null(message.RawRepresentation); + + AdditionalPropertiesDictionary props = []; + + message.AdditionalProperties = props; + Assert.Same(props, message.AdditionalProperties); + + message.AdditionalProperties = props; + Assert.Same(props, message.AdditionalProperties); + + message.AdditionalProperties = null; + Assert.Null(message.AdditionalProperties); + + message.AdditionalProperties = props; + Assert.Same(props, message.AdditionalProperties); + } + + [Fact] + public void ItCanBeSerializeAndDeserialized() + { + // Arrange + IList items = + [ + new TextContent("content-1") + { + ModelId = "model-1", + AdditionalProperties = new() { ["metadata-key-1"] = "metadata-value-1" } + }, + new ImageContent(new Uri("https://fake-random-test-host:123"), "mime-type/2") + { + ModelId = "model-2", + AdditionalProperties = new() { ["metadata-key-2"] = "metadata-value-2" } + }, + new DataContent(new BinaryData(new[] { 1, 2, 3 }, options: TestJsonSerializerContext.Default.Options), "mime-type/3") + { + ModelId = "model-3", + AdditionalProperties = new() { ["metadata-key-3"] = "metadata-value-3" } + }, + new AudioContent(new BinaryData(new[] { 3, 2, 1 }, options: TestJsonSerializerContext.Default.Options), "mime-type/4") + { + ModelId = "model-4", + AdditionalProperties = new() { ["metadata-key-4"] = "metadata-value-4" } + }, + new ImageContent(new BinaryData(new[] { 2, 1, 3 }, options: TestJsonSerializerContext.Default.Options), "mime-type/5") + { + ModelId = "model-5", + AdditionalProperties = new() { ["metadata-key-5"] = "metadata-value-5" } + }, + new TextContent("content-6") + { + ModelId = "model-6", + AdditionalProperties = new() { ["metadata-key-6"] = "metadata-value-6" } + }, + new FunctionCallContent("function-id", "plugin-name-function-name", new Dictionary { ["parameter"] = "argument" }), + new FunctionResultContent(new FunctionCallContent("function-id", "plugin-name-function-name"), "function-result"), + ]; + + // Act + var chatMessageJson = JsonSerializer.Serialize(new ChatMessage(ChatRole.User, contents: items) + { + Text = "content-1-override", // Override the content of the first text content item that has the "content-1" content + AuthorName = "Fred", + AdditionalProperties = new() { ["message-metadata-key-1"] = "message-metadata-value-1" }, + }, TestJsonSerializerContext.Default.Options); + + var deserializedMessage = JsonSerializer.Deserialize(chatMessageJson, TestJsonSerializerContext.Default.Options)!; + + // Assert + Assert.Equal("Fred", deserializedMessage.AuthorName); + Assert.Equal("user", deserializedMessage.Role.Value); + Assert.NotNull(deserializedMessage.AdditionalProperties); + Assert.Single(deserializedMessage.AdditionalProperties); + Assert.Equal("message-metadata-value-1", deserializedMessage.AdditionalProperties["message-metadata-key-1"]?.ToString()); + + Assert.NotNull(deserializedMessage.Contents); + Assert.Equal(items.Count, deserializedMessage.Contents.Count); + + var textContent = deserializedMessage.Contents[0] as TextContent; + Assert.NotNull(textContent); + Assert.Equal("content-1-override", textContent.Text); + Assert.Equal("model-1", textContent.ModelId); + Assert.NotNull(textContent.AdditionalProperties); + Assert.Single(textContent.AdditionalProperties); + Assert.Equal("metadata-value-1", textContent.AdditionalProperties["metadata-key-1"]?.ToString()); + + var imageContent = deserializedMessage.Contents[1] as ImageContent; + Assert.NotNull(imageContent); + Assert.Equal("https://fake-random-test-host:123/", imageContent.Uri); + Assert.Equal("model-2", imageContent.ModelId); + Assert.Equal("mime-type/2", imageContent.MediaType); + Assert.NotNull(imageContent.AdditionalProperties); + Assert.Single(imageContent.AdditionalProperties); + Assert.Equal("metadata-value-2", imageContent.AdditionalProperties["metadata-key-2"]?.ToString()); + + var dataContent = deserializedMessage.Contents[2] as DataContent; + Assert.NotNull(dataContent); + Assert.True(dataContent.Data!.Value.Span.SequenceEqual(new BinaryData(new[] { 1, 2, 3 }, TestJsonSerializerContext.Default.Options))); + Assert.Equal("model-3", dataContent.ModelId); + Assert.Equal("mime-type/3", dataContent.MediaType); + Assert.NotNull(dataContent.AdditionalProperties); + Assert.Single(dataContent.AdditionalProperties); + Assert.Equal("metadata-value-3", dataContent.AdditionalProperties["metadata-key-3"]?.ToString()); + + var audioContent = deserializedMessage.Contents[3] as AudioContent; + Assert.NotNull(audioContent); + Assert.True(audioContent.Data!.Value.Span.SequenceEqual(new BinaryData(new[] { 3, 2, 1 }, TestJsonSerializerContext.Default.Options))); + Assert.Equal("model-4", audioContent.ModelId); + Assert.Equal("mime-type/4", audioContent.MediaType); + Assert.NotNull(audioContent.AdditionalProperties); + Assert.Single(audioContent.AdditionalProperties); + Assert.Equal("metadata-value-4", audioContent.AdditionalProperties["metadata-key-4"]?.ToString()); + + imageContent = deserializedMessage.Contents[4] as ImageContent; + Assert.NotNull(imageContent); + Assert.True(imageContent.Data?.Span.SequenceEqual(new BinaryData(new[] { 2, 1, 3 }, TestJsonSerializerContext.Default.Options))); + Assert.Equal("model-5", imageContent.ModelId); + Assert.Equal("mime-type/5", imageContent.MediaType); + Assert.NotNull(imageContent.AdditionalProperties); + Assert.Single(imageContent.AdditionalProperties); + Assert.Equal("metadata-value-5", imageContent.AdditionalProperties["metadata-key-5"]?.ToString()); + + textContent = deserializedMessage.Contents[5] as TextContent; + Assert.NotNull(textContent); + Assert.Equal("content-6", textContent.Text); + Assert.Equal("model-6", textContent.ModelId); + Assert.NotNull(textContent.AdditionalProperties); + Assert.Single(textContent.AdditionalProperties); + Assert.Equal("metadata-value-6", textContent.AdditionalProperties["metadata-key-6"]?.ToString()); + + var functionCallContent = deserializedMessage.Contents[6] as FunctionCallContent; + Assert.NotNull(functionCallContent); + Assert.Equal("plugin-name-function-name", functionCallContent.Name); + Assert.Equal("function-id", functionCallContent.CallId); + Assert.NotNull(functionCallContent.Arguments); + Assert.Single(functionCallContent.Arguments); + Assert.Equal("argument", functionCallContent.Arguments["parameter"]?.ToString()); + + var functionResultContent = deserializedMessage.Contents[7] as FunctionResultContent; + Assert.NotNull(functionResultContent); + Assert.Equal("function-result", functionResultContent.Result?.ToString()); + Assert.Equal("function-id", functionResultContent.CallId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs new file mode 100644 index 00000000000..2e769ff6d7e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ChatOptionsTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + ChatOptions options = new(); + Assert.Null(options.Temperature); + Assert.Null(options.MaxOutputTokens); + Assert.Null(options.TopP); + Assert.Null(options.FrequencyPenalty); + Assert.Null(options.PresencePenalty); + Assert.Null(options.ResponseFormat); + Assert.Null(options.ModelId); + Assert.Null(options.StopSequences); + Assert.Same(ChatToolMode.Auto, options.ToolMode); + Assert.Null(options.Tools); + Assert.Null(options.AdditionalProperties); + + ChatOptions clone = options.Clone(); + Assert.Null(clone.Temperature); + Assert.Null(clone.MaxOutputTokens); + Assert.Null(clone.TopP); + Assert.Null(clone.FrequencyPenalty); + Assert.Null(clone.PresencePenalty); + Assert.Null(clone.ResponseFormat); + Assert.Null(clone.ModelId); + Assert.Null(clone.StopSequences); + Assert.Same(ChatToolMode.Auto, clone.ToolMode); + Assert.Null(clone.Tools); + Assert.Null(clone.AdditionalProperties); + } + + [Fact] + public void Properties_Roundtrip() + { + ChatOptions options = new(); + + List stopSequences = + [ + "stop1", + "stop2", + ]; + + List tools = + [ + AIFunctionFactory.Create(() => 42), + AIFunctionFactory.Create(() => 43), + ]; + + AdditionalPropertiesDictionary additionalProps = new() + { + ["key"] = "value", + }; + + options.Temperature = 0.1f; + options.MaxOutputTokens = 2; + options.TopP = 0.3f; + options.FrequencyPenalty = 0.4f; + options.PresencePenalty = 0.5f; + options.ResponseFormat = ChatResponseFormat.Json; + options.ModelId = "modelId"; + options.StopSequences = stopSequences; + options.ToolMode = ChatToolMode.RequireAny; + options.Tools = tools; + options.AdditionalProperties = additionalProps; + + Assert.Equal(0.1f, options.Temperature); + Assert.Equal(2, options.MaxOutputTokens); + Assert.Equal(0.3f, options.TopP); + Assert.Equal(0.4f, options.FrequencyPenalty); + Assert.Equal(0.5f, options.PresencePenalty); + Assert.Same(ChatResponseFormat.Json, options.ResponseFormat); + Assert.Equal("modelId", options.ModelId); + Assert.Same(stopSequences, options.StopSequences); + Assert.Same(ChatToolMode.RequireAny, options.ToolMode); + Assert.Same(tools, options.Tools); + Assert.Same(additionalProps, options.AdditionalProperties); + + ChatOptions clone = options.Clone(); + Assert.Equal(0.1f, clone.Temperature); + Assert.Equal(2, clone.MaxOutputTokens); + Assert.Equal(0.3f, clone.TopP); + Assert.Equal(0.4f, clone.FrequencyPenalty); + Assert.Equal(0.5f, clone.PresencePenalty); + Assert.Same(ChatResponseFormat.Json, clone.ResponseFormat); + Assert.Equal("modelId", clone.ModelId); + Assert.Equal(stopSequences, clone.StopSequences); + Assert.Same(ChatToolMode.RequireAny, clone.ToolMode); + Assert.Equal(tools, clone.Tools); + Assert.Equal(additionalProps, clone.AdditionalProperties); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + ChatOptions options = new(); + + List stopSequences = + [ + "stop1", + "stop2", + ]; + + AdditionalPropertiesDictionary additionalProps = new() + { + ["key"] = "value", + }; + + options.Temperature = 0.1f; + options.MaxOutputTokens = 2; + options.TopP = 0.3f; + options.FrequencyPenalty = 0.4f; + options.PresencePenalty = 0.5f; + options.ResponseFormat = ChatResponseFormat.Json; + options.ModelId = "modelId"; + options.StopSequences = stopSequences; + options.ToolMode = ChatToolMode.RequireAny; + options.Tools = + [ + AIFunctionFactory.Create(() => 42), + AIFunctionFactory.Create(() => 43), + ]; + options.AdditionalProperties = additionalProps; + + string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.ChatOptions); + + ChatOptions? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatOptions); + Assert.NotNull(deserialized); + + Assert.Equal(0.1f, deserialized.Temperature); + Assert.Equal(2, deserialized.MaxOutputTokens); + Assert.Equal(0.3f, deserialized.TopP); + Assert.Equal(0.4f, deserialized.FrequencyPenalty); + Assert.Equal(0.5f, deserialized.PresencePenalty); + Assert.Equal(ChatResponseFormat.Json, deserialized.ResponseFormat); + Assert.NotSame(ChatResponseFormat.Json, deserialized.ResponseFormat); + Assert.Equal("modelId", deserialized.ModelId); + Assert.NotSame(stopSequences, deserialized.StopSequences); + Assert.Equal(stopSequences, deserialized.StopSequences); + Assert.Equal(ChatToolMode.RequireAny, deserialized.ToolMode); + Assert.Null(deserialized.Tools); + + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Single(deserialized.AdditionalProperties); + Assert.True(deserialized.AdditionalProperties.TryGetValue("key", out object? value)); + Assert.IsType(value); + Assert.Equal("value", ((JsonElement)value!).GetString()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs new file mode 100644 index 00000000000..f4a63f34e05 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ChatResponseFormatTests +{ + [Fact] + public void Singletons_Idempotent() + { + Assert.Same(ChatResponseFormat.Text, ChatResponseFormat.Text); + Assert.Same(ChatResponseFormat.Json, ChatResponseFormat.Json); + } + + [Fact] + public void Constructor_InvalidArgs_Throws() + { + Assert.Throws(() => new ChatResponseFormatJson(null, "name")); + Assert.Throws(() => new ChatResponseFormatJson(null, null, "description")); + Assert.Throws(() => new ChatResponseFormatJson(null, "name", "description")); + } + + [Fact] + public void Constructor_PropsDefaulted() + { + ChatResponseFormatJson f = new(null); + Assert.Null(f.Schema); + Assert.Null(f.SchemaName); + Assert.Null(f.SchemaDescription); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + ChatResponseFormatJson f = new("{}", "name", "description"); + Assert.Equal("{}", f.Schema); + Assert.Equal("name", f.SchemaName); + Assert.Equal("description", f.SchemaDescription); + } + + [Fact] + public void Equality_ComparersProduceExpectedResults() + { + Assert.True(ChatResponseFormat.Text == ChatResponseFormat.Text); + Assert.True(ChatResponseFormat.Text.Equals(ChatResponseFormat.Text)); + Assert.Equal(ChatResponseFormat.Text.GetHashCode(), ChatResponseFormat.Text.GetHashCode()); + Assert.False(ChatResponseFormat.Text.Equals(ChatResponseFormat.Json)); + Assert.False(ChatResponseFormat.Text.Equals(new ChatResponseFormatJson(null))); + Assert.False(ChatResponseFormat.Text.Equals(new ChatResponseFormatJson("{}"))); + + Assert.True(ChatResponseFormat.Json == ChatResponseFormat.Json); + Assert.True(ChatResponseFormat.Json.Equals(ChatResponseFormat.Json)); + Assert.False(ChatResponseFormat.Json.Equals(ChatResponseFormat.Text)); + Assert.False(ChatResponseFormat.Json.Equals(new ChatResponseFormatJson("{}"))); + + Assert.True(ChatResponseFormat.Json.Equals(new ChatResponseFormatJson(null))); + Assert.Equal(ChatResponseFormat.Json.GetHashCode(), new ChatResponseFormatJson(null).GetHashCode()); + + Assert.True(new ChatResponseFormatJson("{}").Equals(new ChatResponseFormatJson("{}"))); + Assert.Equal(new ChatResponseFormatJson("{}").GetHashCode(), new ChatResponseFormatJson("{}").GetHashCode()); + + Assert.False(new ChatResponseFormatJson("""{ "prop": 42 }""").Equals(new ChatResponseFormatJson("""{ "prop": 43 }"""))); + Assert.NotEqual(new ChatResponseFormatJson("""{ "prop": 42 }""").GetHashCode(), new ChatResponseFormatJson("""{ "prop": 43 }""").GetHashCode()); // technically not guaranteed + + Assert.False(new ChatResponseFormatJson("""{ "prop": 42 }""").Equals(new ChatResponseFormatJson("""{ "PROP": 42 }"""))); + Assert.NotEqual(new ChatResponseFormatJson("""{ "prop": 42 }""").GetHashCode(), new ChatResponseFormatJson("""{ "PROP": 42 }""").GetHashCode()); // technically not guaranteed + + Assert.True(new ChatResponseFormatJson("{}", "name", "description").Equals(new ChatResponseFormatJson("{}", "name", "description"))); + Assert.False(new ChatResponseFormatJson("{}", "name", "description").Equals(new ChatResponseFormatJson("{}", "name", "description2"))); + Assert.False(new ChatResponseFormatJson("{}", "name", "description").Equals(new ChatResponseFormatJson("{}", "name2", "description"))); + Assert.False(new ChatResponseFormatJson("{}", "name", "description").Equals(new ChatResponseFormatJson("{}", "name2", "description2"))); + + Assert.Equal(new ChatResponseFormatJson("{}", "name", "description").GetHashCode(), new ChatResponseFormatJson("{}", "name", "description").GetHashCode()); + } + + [Fact] + public void Serialization_TextRoundtrips() + { + string json = JsonSerializer.Serialize(ChatResponseFormat.Text, TestJsonSerializerContext.Default.ChatResponseFormat); + Assert.Equal("""{"$type":"text"}""", json); + + ChatResponseFormat? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatResponseFormat); + Assert.Equal(ChatResponseFormat.Text, result); + } + + [Fact] + public void Serialization_JsonRoundtrips() + { + string json = JsonSerializer.Serialize(ChatResponseFormat.Json, TestJsonSerializerContext.Default.ChatResponseFormat); + Assert.Equal("""{"$type":"json"}""", json); + + ChatResponseFormat? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatResponseFormat); + Assert.Equal(ChatResponseFormat.Json, result); + } + + [Fact] + public void Serialization_ForJsonSchemaRoundtrips() + { + string json = JsonSerializer.Serialize(ChatResponseFormat.ForJsonSchema("[1,2,3]", "name", "description"), TestJsonSerializerContext.Default.ChatResponseFormat); + Assert.Equal("""{"$type":"json","schema":"[1,2,3]","schemaName":"name","schemaDescription":"description"}""", json); + + ChatResponseFormat? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatResponseFormat); + Assert.Equal(ChatResponseFormat.ForJsonSchema("[1,2,3]", "name", "description"), result); + Assert.Equal("[1,2,3]", (result as ChatResponseFormatJson)?.Schema); + Assert.Equal("name", (result as ChatResponseFormatJson)?.SchemaName); + Assert.Equal("description", (result as ChatResponseFormatJson)?.SchemaDescription); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatRoleTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatRoleTests.cs new file mode 100644 index 00000000000..7761aa2fdc3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatRoleTests.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ChatRoleTests +{ + [Fact] + public void Constructor_Value_Roundtrips() + { + Assert.Equal("abc", new ChatRole("abc").Value); + } + + [Fact] + public void Constructor_NullOrWhiteSpace_Throws() + { + Assert.Throws(() => new ChatRole(null!)); + Assert.Throws(() => new ChatRole(" ")); + } + + [Fact] + public void Equality_UsesOrdinalIgnoreCaseComparison() + { + Assert.True(new ChatRole("abc").Equals(new ChatRole("ABC"))); + Assert.True(new ChatRole("abc").Equals((object)new ChatRole("ABC"))); + Assert.True(new ChatRole("abc") == new ChatRole("ABC")); + Assert.False(new ChatRole("abc") != new ChatRole("ABC")); + + Assert.False(new ChatRole("abc").Equals(new ChatRole("def"))); + Assert.False(new ChatRole("abc").Equals((object)new ChatRole("def"))); + Assert.False(new ChatRole("abc").Equals(null)); + Assert.False(new ChatRole("abc").Equals("abc")); + Assert.False(new ChatRole("abc") == new ChatRole("def")); + Assert.True(new ChatRole("abc") != new ChatRole("def")); + + Assert.Equal(new ChatRole("abc").GetHashCode(), new ChatRole("abc").GetHashCode()); + Assert.Equal(new ChatRole("abc").GetHashCode(), new ChatRole("ABC").GetHashCode()); + Assert.NotEqual(new ChatRole("abc").GetHashCode(), new ChatRole("def").GetHashCode()); // not guaranteed + } + + [Fact] + public void Singletons_UseKnownValues() + { + Assert.Equal("assistant", ChatRole.Assistant.Value); + Assert.Equal("system", ChatRole.System.Value); + Assert.Equal("tool", ChatRole.Tool.Value); + Assert.Equal("user", ChatRole.User.Value); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + ChatRole role = new("abc"); + string? json = JsonSerializer.Serialize(role, TestJsonSerializerContext.Default.ChatRole); + Assert.Equal("\"abc\"", json); + + ChatRole? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatRole); + Assert.Equal(role, result); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatToolModeTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatToolModeTests.cs new file mode 100644 index 00000000000..7cdda8ef975 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatToolModeTests.cs @@ -0,0 +1,76 @@ +// 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; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ChatToolModeTests +{ + [Fact] + public void Singletons_Idempotent() + { + Assert.Same(ChatToolMode.Auto, ChatToolMode.Auto); + Assert.Same(ChatToolMode.RequireAny, ChatToolMode.RequireAny); + } + + [Fact] + public void Equality_ComparersProduceExpectedResults() + { + Assert.True(ChatToolMode.Auto == ChatToolMode.Auto); + Assert.True(ChatToolMode.Auto.Equals(ChatToolMode.Auto)); + Assert.False(ChatToolMode.Auto.Equals(ChatToolMode.RequireAny)); + Assert.False(ChatToolMode.Auto.Equals(new RequiredChatToolMode(null))); + Assert.False(ChatToolMode.Auto.Equals(new RequiredChatToolMode("func"))); + Assert.Equal(ChatToolMode.Auto.GetHashCode(), ChatToolMode.Auto.GetHashCode()); + + Assert.True(ChatToolMode.RequireAny == ChatToolMode.RequireAny); + Assert.True(ChatToolMode.RequireAny.Equals(ChatToolMode.RequireAny)); + Assert.False(ChatToolMode.RequireAny.Equals(ChatToolMode.Auto)); + Assert.False(ChatToolMode.RequireAny.Equals(new RequiredChatToolMode("func"))); + + Assert.True(ChatToolMode.RequireAny.Equals(new RequiredChatToolMode(null))); + Assert.Equal(ChatToolMode.RequireAny.GetHashCode(), new RequiredChatToolMode(null).GetHashCode()); + Assert.Equal(ChatToolMode.RequireAny.GetHashCode(), ChatToolMode.RequireAny.GetHashCode()); + + Assert.True(new RequiredChatToolMode("func").Equals(new RequiredChatToolMode("func"))); + Assert.Equal(new RequiredChatToolMode("func").GetHashCode(), new RequiredChatToolMode("func").GetHashCode()); + + Assert.False(new RequiredChatToolMode("func1").Equals(new RequiredChatToolMode("func2"))); + Assert.NotEqual(new RequiredChatToolMode("func1").GetHashCode(), new RequiredChatToolMode("func2").GetHashCode()); // technically not guaranteed + + Assert.False(new RequiredChatToolMode("func1").Equals(new RequiredChatToolMode("FUNC1"))); + Assert.NotEqual(new RequiredChatToolMode("func1").GetHashCode(), new RequiredChatToolMode("FUNC1").GetHashCode()); // technically not guaranteed + } + + [Fact] + public void Serialization_AutoRoundtrips() + { + string json = JsonSerializer.Serialize(ChatToolMode.Auto, TestJsonSerializerContext.Default.ChatToolMode); + Assert.Equal("""{"$type":"auto"}""", json); + + ChatToolMode? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatToolMode); + Assert.Equal(ChatToolMode.Auto, result); + } + + [Fact] + public void Serialization_RequireAnyRoundtrips() + { + string json = JsonSerializer.Serialize(ChatToolMode.RequireAny, TestJsonSerializerContext.Default.ChatToolMode); + Assert.Equal("""{"$type":"required"}""", json); + + ChatToolMode? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatToolMode); + Assert.Equal(ChatToolMode.RequireAny, result); + } + + [Fact] + public void Serialization_RequireSpecificRoundtrips() + { + string json = JsonSerializer.Serialize(ChatToolMode.RequireSpecific("myFunc"), TestJsonSerializerContext.Default.ChatToolMode); + Assert.Equal("""{"$type":"required","requiredFunctionName":"myFunc"}""", json); + + ChatToolMode? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatToolMode); + Assert.Equal(ChatToolMode.RequireSpecific("myFunc"), result); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs new file mode 100644 index 00000000000..51c82c7dcb7 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DelegatingChatClientTests +{ + [Fact] + public void RequiresInnerChatClient() + { + Assert.Throws(() => new NoOpDelegatingChatClient(null!)); + } + + [Fact] + public void MetadataDefaultsToInnerClient() + { + using var inner = new TestChatClient(); + using var delegating = new NoOpDelegatingChatClient(inner); + + Assert.Same(inner.Metadata, delegating.Metadata); + } + + [Fact] + public async Task ChatAsyncDefaultsToInnerClientAsync() + { + // Arrange + var expectedChatContents = new List(); + var expectedChatOptions = new ChatOptions(); + var expectedCancellationToken = CancellationToken.None; + var expectedResult = new TaskCompletionSource(); + var expectedCompletion = new ChatCompletion([]); + using var inner = new TestChatClient + { + CompleteAsyncCallback = (chatContents, options, cancellationToken) => + { + Assert.Same(expectedChatContents, chatContents); + Assert.Same(expectedChatOptions, options); + Assert.Equal(expectedCancellationToken, cancellationToken); + return expectedResult.Task; + } + }; + + using var delegating = new NoOpDelegatingChatClient(inner); + + // Act + var resultTask = delegating.CompleteAsync(expectedChatContents, expectedChatOptions, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(expectedCompletion); + Assert.True(resultTask.IsCompleted); + Assert.Same(expectedCompletion, await resultTask); + } + + [Fact] + public async Task ChatStreamingAsyncDefaultsToInnerClientAsync() + { + // Arrange + var expectedChatContents = new List(); + var expectedChatOptions = new ChatOptions(); + var expectedCancellationToken = CancellationToken.None; + StreamingChatCompletionUpdate[] expectedResults = + [ + new() { Role = ChatRole.User, Text = "Message 1" }, + new() { Role = ChatRole.User, Text = "Message 2" } + ]; + + using var inner = new TestChatClient + { + CompleteStreamingAsyncCallback = (chatContents, options, cancellationToken) => + { + Assert.Same(expectedChatContents, chatContents); + Assert.Same(expectedChatOptions, options); + Assert.Equal(expectedCancellationToken, cancellationToken); + return YieldAsync(expectedResults); + } + }; + + using var delegating = new NoOpDelegatingChatClient(inner); + + // Act + var resultAsyncEnumerable = delegating.CompleteStreamingAsync(expectedChatContents, expectedChatOptions, expectedCancellationToken); + + // Assert + var enumerator = resultAsyncEnumerable.GetAsyncEnumerator(); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Same(expectedResults[0], enumerator.Current); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Same(expectedResults[1], enumerator.Current); + Assert.False(await enumerator.MoveNextAsync()); + } + + [Fact] + public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() + { + // Arrange + using var inner = new TestChatClient(); + using var delegating = new NoOpDelegatingChatClient(inner); + + // Act + var client = delegating.GetService(); + + // Assert + Assert.Same(delegating, client); + } + + [Fact] + public void GetServiceDelegatesToInnerIfKeyIsNotNull() + { + // Arrange + var expectedParam = new object(); + var expectedKey = new object(); + using var expectedResult = new TestChatClient(); + using var inner = new TestChatClient + { + GetServiceCallback = (_, _) => expectedResult + }; + using var delegating = new NoOpDelegatingChatClient(inner); + + // Act + var client = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, client); + } + + [Fact] + public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest() + { + // Arrange + var expectedParam = new object(); + var expectedResult = TimeZoneInfo.Local; + var expectedKey = new object(); + using var inner = new TestChatClient + { + GetServiceCallback = (type, key) => type == expectedResult.GetType() && key == expectedKey + ? expectedResult + : throw new InvalidOperationException("Unexpected call") + }; + using var delegating = new NoOpDelegatingChatClient(inner); + + // Act + var tzi = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, tzi); + } + + private static async IAsyncEnumerable YieldAsync(IEnumerable input) + { + await Task.Yield(); + foreach (var item in input) + { + yield return item; + } + } + + private sealed class NoOpDelegatingChatClient(IChatClient innerClient) + : DelegatingChatClient(innerClient); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs new file mode 100644 index 00000000000..988727b1159 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs @@ -0,0 +1,220 @@ +// 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 StreamingChatCompletionUpdateTests +{ + [Fact] + public void Constructor_PropsDefaulted() + { + StreamingChatCompletionUpdate update = new(); + Assert.Null(update.AuthorName); + Assert.Null(update.Role); + Assert.Null(update.Text); + Assert.Empty(update.Contents); + Assert.Null(update.RawRepresentation); + Assert.Null(update.AdditionalProperties); + Assert.Null(update.CompletionId); + Assert.Null(update.CreatedAt); + Assert.Null(update.FinishReason); + Assert.Equal(0, update.ChoiceIndex); + Assert.Equal(string.Empty, update.ToString()); + } + + [Fact] + public void Properties_Roundtrip() + { + StreamingChatCompletionUpdate update = new(); + + Assert.Null(update.AuthorName); + update.AuthorName = "author"; + Assert.Equal("author", update.AuthorName); + + Assert.Null(update.Role); + update.Role = ChatRole.Assistant; + Assert.Equal(ChatRole.Assistant, update.Role); + + Assert.Empty(update.Contents); + update.Contents.Add(new TextContent("text")); + Assert.Single(update.Contents); + Assert.Equal("text", update.Text); + Assert.Same(update.Contents, update.Contents); + IList newList = [new TextContent("text")]; + update.Contents = newList; + Assert.Same(newList, update.Contents); + update.Contents = null; + Assert.NotNull(update.Contents); + Assert.Empty(update.Contents); + + Assert.Null(update.Text); + update.Text = "text"; + Assert.Equal("text", update.Text); + + Assert.Null(update.RawRepresentation); + object raw = new(); + update.RawRepresentation = raw; + Assert.Same(raw, update.RawRepresentation); + + Assert.Null(update.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { ["key"] = "value" }; + update.AdditionalProperties = props; + Assert.Same(props, update.AdditionalProperties); + + Assert.Null(update.CompletionId); + update.CompletionId = "id"; + Assert.Equal("id", update.CompletionId); + + Assert.Null(update.CreatedAt); + update.CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero); + Assert.Equal(new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), update.CreatedAt); + + Assert.Equal(0, update.ChoiceIndex); + update.ChoiceIndex = 42; + Assert.Equal(42, update.ChoiceIndex); + + Assert.Null(update.FinishReason); + update.FinishReason = ChatFinishReason.ContentFilter; + Assert.Equal(ChatFinishReason.ContentFilter, update.FinishReason); + } + + [Fact] + public void Text_GetSet_UsesFirstTextContent() + { + StreamingChatCompletionUpdate update = new() + { + Role = ChatRole.User, + Contents = + [ + new AudioContent("http://localhost/audio"), + new ImageContent("http://localhost/image"), + new FunctionCallContent("callId1", "fc1"), + new TextContent("text-1"), + new TextContent("text-2"), + new FunctionResultContent(new FunctionCallContent("callId1", "fc2"), "result"), + ], + }; + + TextContent textContent = Assert.IsType(update.Contents[3]); + Assert.Equal("text-1", textContent.Text); + Assert.Equal("text-1", update.Text); + Assert.Equal("text-1", update.ToString()); + + update.Text = "text-3"; + Assert.Equal("text-3", update.Text); + Assert.Equal("text-3", update.Text); + Assert.Same(textContent, update.Contents[3]); + Assert.Equal("text-3", update.ToString()); + } + + [Fact] + public void Text_Set_AddsTextMessageToEmptyList() + { + StreamingChatCompletionUpdate update = new() + { + Role = ChatRole.User, + }; + Assert.Empty(update.Contents); + + update.Text = "text-1"; + Assert.Equal("text-1", update.Text); + + Assert.Single(update.Contents); + TextContent textContent = Assert.IsType(update.Contents[0]); + Assert.Equal("text-1", textContent.Text); + } + + [Fact] + public void Text_Set_AddsTextMessageToListWithNoText() + { + StreamingChatCompletionUpdate update = new() + { + Contents = + [ + new AudioContent("http://localhost/audio"), + new ImageContent("http://localhost/image"), + new FunctionCallContent("callId1", "fc1"), + ] + }; + Assert.Equal(3, update.Contents.Count); + + update.Text = "text-1"; + Assert.Equal("text-1", update.Text); + Assert.Equal(4, update.Contents.Count); + + update.Text = "text-2"; + Assert.Equal("text-2", update.Text); + Assert.Equal(4, update.Contents.Count); + + update.Contents.RemoveAt(3); + Assert.Equal(3, update.Contents.Count); + + update.Text = "text-3"; + Assert.Equal("text-3", update.Text); + Assert.Equal(4, update.Contents.Count); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + StreamingChatCompletionUpdate original = new() + { + AuthorName = "author", + Role = ChatRole.Assistant, + Contents = + [ + new TextContent("text-1"), + new ImageContent("http://localhost/image"), + new FunctionCallContent("callId1", "fc1"), + new DataContent("data"u8.ToArray()), + new TextContent("text-2"), + ], + RawRepresentation = new object(), + CompletionId = "id", + CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), + FinishReason = ChatFinishReason.ContentFilter, + AdditionalProperties = new() { ["key"] = "value" }, + ChoiceIndex = 42, + }; + + string json = JsonSerializer.Serialize(original, TestJsonSerializerContext.Default.StreamingChatCompletionUpdate); + + StreamingChatCompletionUpdate? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.StreamingChatCompletionUpdate); + + Assert.NotNull(result); + Assert.Equal(5, result.Contents.Count); + + Assert.IsType(result.Contents[0]); + Assert.Equal("text-1", ((TextContent)result.Contents[0]).Text); + + Assert.IsType(result.Contents[1]); + Assert.Equal("http://localhost/image", ((ImageContent)result.Contents[1]).Uri); + + Assert.IsType(result.Contents[2]); + Assert.Equal("fc1", ((FunctionCallContent)result.Contents[2]).Name); + + Assert.IsType(result.Contents[3]); + Assert.Equal("data"u8.ToArray(), ((DataContent)result.Contents[3]).Data?.ToArray()); + + Assert.IsType(result.Contents[4]); + Assert.Equal("text-2", ((TextContent)result.Contents[4]).Text); + + Assert.Equal("author", result.AuthorName); + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.Equal("id", result.CompletionId); + Assert.Equal(new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), result.CreatedAt); + Assert.Equal(ChatFinishReason.ContentFilter, result.FinishReason); + Assert.Equal(42, result.ChoiceIndex); + + Assert.NotNull(result.AdditionalProperties); + Assert.Single(result.AdditionalProperties); + Assert.True(result.AdditionalProperties.TryGetValue("key", out object? value)); + Assert.IsType(value); + Assert.Equal("value", ((JsonElement)value!).GetString()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs new file mode 100644 index 00000000000..ece02f017bb --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.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 Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + DerivedAIContent c = new(); + Assert.Null(c.RawRepresentation); + Assert.Null(c.ModelId); + Assert.Null(c.AdditionalProperties); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + DerivedAIContent c = new(); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.ModelId); + c.ModelId = "modelId"; + Assert.Equal("modelId", c.ModelId); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + } + + private sealed class DerivedAIContent : AIContent; +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AudioContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AudioContentTests.cs new file mode 100644 index 00000000000..7aff849e8a1 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AudioContentTests.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public sealed class AudioContentTests : DataContentTests; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs new file mode 100644 index 00000000000..18aae8c0497 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public sealed class DataContentTests : DataContentTests; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs new file mode 100644 index 00000000000..ea3017cf7ea --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs @@ -0,0 +1,249 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Reflection; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public abstract class DataContentTests + where T : DataContent +{ + private static T Create(params object?[] args) + { + try + { + return (T)Activator.CreateInstance(typeof(T), args)!; + } + catch (TargetInvocationException e) + { + throw e.InnerException!; + } + } + + public T CreateDataContent(Uri uri, string? mediaType = null) => Create(uri, mediaType)!; + +#pragma warning disable S3997 // String URI overloads should call "System.Uri" overloads + public T CreateDataContent(string uriString, string? mediaType = null) => Create(uriString, mediaType)!; +#pragma warning restore S3997 + + public T CreateDataContent(ReadOnlyMemory data, string? mediaType = null) => Create(data, mediaType)!; + + [Theory] + + // Invalid URI + [InlineData("", typeof(ArgumentException))] + [InlineData("invalid", typeof(UriFormatException))] + + // Format errors + [InlineData("data", typeof(UriFormatException))] // data missing colon + [InlineData("data:", typeof(UriFormatException))] // data missing comma + [InlineData("data:something,", typeof(UriFormatException))] // mime type without subtype + [InlineData("data:something;else,data", typeof(UriFormatException))] // mime type without subtype + [InlineData("data:type/subtype;;parameter=value;else,", typeof(UriFormatException))] // parameter without value + [InlineData("data:type/subtype;parameter=va=lue;else,", typeof(UriFormatException))] // parameter with multiple = + [InlineData("data:type/subtype;=value;else,", typeof(UriFormatException))] // empty parameter name + [InlineData("data:image/j/peg;base64,/9j/4AAQSkZJRgABAgAAZABkAAD", typeof(UriFormatException))] // multiple slashes in media type + + // Base64 Validation Errors + [InlineData("data:text;base64,something!", typeof(UriFormatException))] // Invalid base64 due to invalid character '!' + [InlineData("data:text/plain;base64,U29tZQ==\t", typeof(UriFormatException))] // Invalid base64 due to tab character + [InlineData("data:text/plain;base64,U29tZQ==\r", typeof(UriFormatException))] // Invalid base64 due to carriage return character + [InlineData("data:text/plain;base64,U29tZQ==\n", typeof(UriFormatException))] // Invalid base64 due to line feed character + [InlineData("data:text/plain;base64,U29t\r\nZQ==", typeof(UriFormatException))] // Invalid base64 due to carriage return and line feed characters + [InlineData("data:text/plain;base64,U29", typeof(UriFormatException))] // Invalid base64 due to missing padding + [InlineData("data:text/plain;base64,U29tZQ", typeof(UriFormatException))] // Invalid base64 due to missing padding + [InlineData("data:text/plain;base64,U29tZQ=", typeof(UriFormatException))] // Invalid base64 due to missing padding + public void Ctor_InvalidUri_Throws(string path, Type exception) + { + Assert.Throws(exception, () => CreateDataContent(path)); + } + + [Theory] + [InlineData("type")] + [InlineData("type//subtype")] + [InlineData("type/subtype/")] + [InlineData("type/subtype;key=")] + [InlineData("type/subtype;=value")] + [InlineData("type/subtype;key=value;another=")] + public void Ctor_InvalidMediaType_Throws(string mediaType) + { + Assert.Throws(() => CreateDataContent("http://localhost/test", mediaType)); + } + + [Theory] + [InlineData("type/subtype")] + [InlineData("type/subtype;key=value")] + [InlineData("type/subtype;key=value;another=value")] + [InlineData("type/subtype;key=value;another=value;yet_another=value")] + public void Ctor_ValidMediaType_Roundtrips(string mediaType) + { + T content = CreateDataContent("http://localhost/test", mediaType); + Assert.Equal(mediaType, content.MediaType); + + content = CreateDataContent("data:,", mediaType); + Assert.Equal(mediaType, content.MediaType); + + content = CreateDataContent("data:text/plain,", mediaType); + Assert.Equal(mediaType, content.MediaType); + + content = CreateDataContent(new Uri("data:text/plain,"), mediaType); + Assert.Equal(mediaType, content.MediaType); + + content = CreateDataContent(new byte[] { 0, 1, 2 }, mediaType); + Assert.Equal(mediaType, content.MediaType); + + content = CreateDataContent(content.Uri); + Assert.Equal(mediaType, content.MediaType); + } + + [Fact] + public void Ctor_NoMediaType_Roundtrips() + { + T content; + + foreach (string url in new[] { "http://localhost/test", "about:something", "file://c:\\path" }) + { + content = CreateDataContent(url); + Assert.Equal(url, content.Uri); + Assert.Null(content.MediaType); + Assert.Null(content.Data); + } + + content = CreateDataContent("data:,something"); + Assert.Equal("data:,something", content.Uri); + Assert.Null(content.MediaType); + Assert.Equal("something"u8.ToArray(), content.Data!.Value.ToArray()); + + content = CreateDataContent("data:,Hello+%3C%3E"); + Assert.Equal("data:,Hello+%3C%3E", content.Uri); + Assert.Null(content.MediaType); + Assert.Equal("Hello <>"u8.ToArray(), content.Data!.Value.ToArray()); + } + + [Fact] + public void Serialize_MatchesExpectedJson() + { + Assert.Equal( + """{"uri":"data:,"}""", + JsonSerializer.Serialize(CreateDataContent("data:,"), TestJsonSerializerContext.Default.Options)); + + Assert.Equal( + """{"uri":"http://localhost/"}""", + JsonSerializer.Serialize(CreateDataContent(new Uri("http://localhost/")), TestJsonSerializerContext.Default.Options)); + + Assert.Equal( + """{"uri":"data:application/octet-stream;base64,AQIDBA==","mediaType":"application/octet-stream"}""", + JsonSerializer.Serialize(CreateDataContent( + uriString: "data:application/octet-stream;base64,AQIDBA=="), TestJsonSerializerContext.Default.Options)); + + Assert.Equal( + """{"uri":"data:application/octet-stream;base64,AQIDBA==","mediaType":"application/octet-stream"}""", + JsonSerializer.Serialize(CreateDataContent( + new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream"), + TestJsonSerializerContext.Default.Options)); + } + + [Theory] + [InlineData("{}")] + [InlineData("""{ "mediaType":"text/plain" }""")] + public void Deserialize_MissingUriString_Throws(string json) + { + Assert.Throws(() => JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options)!); + } + + [Fact] + public void Deserialize_MatchesExpectedData() + { + // Data + MimeType only + var content = JsonSerializer.Deserialize("""{"mediaType":"application/octet-stream","uri":"data:;base64,AQIDBA=="}""", TestJsonSerializerContext.Default.Options)!; + + Assert.Equal("data:application/octet-stream;base64,AQIDBA==", content.Uri); + Assert.NotNull(content.Data); + Assert.Equal([0x01, 0x02, 0x03, 0x04], content.Data!.Value.ToArray()); + Assert.Equal("application/octet-stream", content.MediaType); + Assert.True(content.ContainsData); + + // Uri referenced content-only + content = JsonSerializer.Deserialize("""{"mediaType":"application/octet-stream","uri":"http://localhost/"}""", TestJsonSerializerContext.Default.Options)!; + + Assert.Null(content.Data); + Assert.Equal("http://localhost/", content.Uri); + Assert.Equal("application/octet-stream", content.MediaType); + Assert.False(content.ContainsData); + + // Using extra metadata + content = JsonSerializer.Deserialize(""" + { + "uri": "data:;base64,AQIDBA==", + "modelId": "gpt-4", + "additionalProperties": + { + "key": "value" + }, + "mediaType": "text/plain" + } + """, TestJsonSerializerContext.Default.Options)!; + + Assert.Equal("data:text/plain;base64,AQIDBA==", content.Uri); + Assert.NotNull(content.Data); + Assert.Equal([0x01, 0x02, 0x03, 0x04], content.Data!.Value.ToArray()); + Assert.Equal("text/plain", content.MediaType); + Assert.True(content.ContainsData); + Assert.Equal("gpt-4", content.ModelId); + Assert.Equal("value", content.AdditionalProperties!["key"]!.ToString()); + } + + [Theory] + [InlineData( + """{"uri": "data:;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType": "text/plain"}""", + """{"uri":"data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType":"text/plain"}""")] + [InlineData( + """{"uri": "data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType": "text/plain"}""", + """{"uri":"data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType":"text/plain"}""")] + [InlineData( // Does not support non-readable content + """{"uri": "data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", "unexpected": true}""", + """{"uri":"data:text/plain;base64,AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=","mediaType":"text/plain"}""")] + [InlineData( // Uri comes before mimetype + """{"mediaType": "text/plain", "uri": "http://localhost/" }""", + """{"uri":"http://localhost/","mediaType":"text/plain"}""")] + public void Serialize_Deserialize_Roundtrips(string serialized, string expectedToString) + { + var content = JsonSerializer.Deserialize(serialized, TestJsonSerializerContext.Default.Options)!; + var reSerialization = JsonSerializer.Serialize(content, TestJsonSerializerContext.Default.Options); + Assert.Equal(expectedToString, reSerialization); + } + + [Theory] + [InlineData("application/json")] + [InlineData("application/octet-stream")] + [InlineData("application/pdf")] + [InlineData("application/xml")] + [InlineData("audio/mpeg")] + [InlineData("audio/ogg")] + [InlineData("audio/wav")] + [InlineData("image/apng")] + [InlineData("image/avif")] + [InlineData("image/bmp")] + [InlineData("image/gif")] + [InlineData("image/jpeg")] + [InlineData("image/png")] + [InlineData("image/svg+xml")] + [InlineData("image/tiff")] + [InlineData("image/webp")] + [InlineData("text/css")] + [InlineData("text/csv")] + [InlineData("text/html")] + [InlineData("text/javascript")] + [InlineData("text/plain")] + [InlineData("text/plain;charset=UTF-8")] + [InlineData("text/xml")] + [InlineData("custom/mediatypethatdoesntexists")] + public void MediaType_Roundtrips(string mediaType) + { + DataContent c = new("data:,", mediaType); + Assert.Equal(mediaType, c.MediaType); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs new file mode 100644 index 00000000000..791bb4cc0e7 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs @@ -0,0 +1,302 @@ +// 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.Collections.ObjectModel; +using System.Linq; +#if NET +using System.Runtime.ExceptionServices; +#endif +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class FunctionCallContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + FunctionCallContent c = new("callId1", "name"); + + Assert.Null(c.RawRepresentation); + Assert.Null(c.ModelId); + Assert.Null(c.AdditionalProperties); + + Assert.Equal("callId1", c.CallId); + Assert.Equal("name", c.Name); + + Assert.Null(c.Arguments); + Assert.Null(c.Exception); + } + + [Fact] + public void Constructor_ArgumentsRoundtrip() + { + Dictionary args = []; + + FunctionCallContent c = new("id", "name", args); + + Assert.Null(c.RawRepresentation); + Assert.Null(c.ModelId); + Assert.Null(c.AdditionalProperties); + + Assert.Equal("name", c.Name); + Assert.Equal("id", c.CallId); + Assert.Same(args, c.Arguments); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + FunctionCallContent c = new("callId1", "name"); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.ModelId); + c.ModelId = "modelId"; + Assert.Equal("modelId", c.ModelId); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + + Assert.Equal("callId1", c.CallId); + c.CallId = "id"; + Assert.Equal("id", c.CallId); + + Assert.Null(c.Arguments); + AdditionalPropertiesDictionary args = new() { { "key", "value" } }; + c.Arguments = args; + Assert.Same(args, c.Arguments); + + Assert.Null(c.Exception); + Exception e = new(); + c.Exception = e; + Assert.Same(e, c.Exception); + } + + [Fact] + public void ItShouldBeSerializableAndDeserializableWithException() + { + // Arrange + var ex = new InvalidOperationException("hello", new NullReferenceException("bye")); +#if NET + ExceptionDispatchInfo.SetRemoteStackTrace(ex, "stack trace"); +#endif + var sut = new FunctionCallContent("callId1", "functionName") { Exception = ex }; + + // Act + var json = JsonSerializer.SerializeToNode(sut, TestJsonSerializerContext.Default.Options); + var deserializedSut = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + // Assert + JsonObject jsonEx = Assert.IsType(json!["exception"]); + Assert.Equal(4, jsonEx.Count); + Assert.Equal("System.InvalidOperationException", (string?)jsonEx["className"]); + Assert.Equal("hello", (string?)jsonEx["message"]); +#if NET + Assert.StartsWith("stack trace", (string?)jsonEx["stackTraceString"]); +#endif + JsonObject jsonExInner = Assert.IsType(jsonEx["innerException"]); + Assert.Equal(4, jsonExInner.Count); + Assert.Equal("System.NullReferenceException", (string?)jsonExInner["className"]); + Assert.Equal("bye", (string?)jsonExInner["message"]); + Assert.Null(jsonExInner["innerException"]); + Assert.Null(jsonExInner["stackTraceString"]); + + Assert.NotNull(deserializedSut); + Assert.IsType(deserializedSut.Exception); + Assert.Equal("hello", deserializedSut.Exception.Message); +#if NET + Assert.StartsWith("stack trace", deserializedSut.Exception.StackTrace); +#endif + + Assert.IsType(deserializedSut.Exception.InnerException); + Assert.Equal("bye", deserializedSut.Exception.InnerException.Message); + Assert.Null(deserializedSut.Exception.InnerException.StackTrace); + Assert.Null(deserializedSut.Exception.InnerException.InnerException); + } + + [Fact] + public async Task AIFunctionFactory_ObjectValues_Converted() + { + Dictionary arguments = new() + { + ["a"] = new DayOfWeek[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday }, + ["b"] = 123.4M, + ["c"] = "072c2d93-7cf6-4d0d-aebc-acc51e6ee7ee", + ["d"] = new ReadOnlyDictionary((new Dictionary + { + ["p1"] = "42", + ["p2"] = "43", + })), + }; + + AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, TestJsonSerializerContext.Default.Options); + var result = await function.InvokeAsync(arguments); + AssertExtensions.EqualFunctionCallResults(123.4, result); + } + + [Fact] + public async Task AIFunctionFactory_JsonElementValues_ValuesDeserialized() + { + Dictionary arguments = JsonSerializer.Deserialize>(""" + { + "a": ["Monday", "Tuesday", "Wednesday"], + "b": 123.4, + "c": "072c2d93-7cf6-4d0d-aebc-acc51e6ee7ee", + "d": { + "property1": "42", + "property2": "43", + "property3": "44" + } + } + """, TestJsonSerializerContext.Default.Options)!; + Assert.All(arguments.Values, v => Assert.IsType(v)); + + AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, TestJsonSerializerContext.Default.Options); + var result = await function.InvokeAsync(arguments); + AssertExtensions.EqualFunctionCallResults(123.4, result); + } + + [Fact] + public void AIFunctionFactory_WhenTypesUnknownByContext_Throws() + { + var ex = Assert.Throws(() => AIFunctionFactory.Create((CustomType arg) => { }, TestJsonSerializerContext.Default.Options)); + Assert.Contains("JsonTypeInfo metadata", ex.Message); + Assert.Contains(nameof(CustomType), ex.Message); + + ex = Assert.Throws(() => AIFunctionFactory.Create(() => new CustomType(), TestJsonSerializerContext.Default.Options)); + Assert.Contains("JsonTypeInfo metadata", ex.Message); + Assert.Contains(nameof(CustomType), ex.Message); + } + + [Fact] + public async Task AIFunctionFactory_JsonDocumentValues_ValuesDeserialized() + { + var arguments = JsonSerializer.Deserialize>(""" + { + "a": ["Monday", "Tuesday", "Wednesday"], + "b": 123.4, + "c": "072c2d93-7cf6-4d0d-aebc-acc51e6ee7ee", + "d": { + "property1": "42", + "property2": "43", + "property3": "44" + } + } + """, TestJsonSerializerContext.Default.Options)!.ToDictionary(k => k.Key, k => (object?)k.Value); + + AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, TestJsonSerializerContext.Default.Options); + var result = await function.InvokeAsync(arguments); + AssertExtensions.EqualFunctionCallResults(123.4, result); + } + + [Fact] + public async Task AIFunctionFactory_JsonNodeValues_ValuesDeserialized() + { + var arguments = JsonSerializer.Deserialize>(""" + { + "a": ["Monday", "Tuesday", "Wednesday"], + "b": 123.4, + "c": "072c2d93-7cf6-4d0d-aebc-acc51e6ee7ee", + "d": { + "property1": "42", + "property2": "43", + "property3": "44" + } + } + """, TestJsonSerializerContext.Default.Options)!.ToDictionary(k => k.Key, k => (object?)k.Value); + + AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, TestJsonSerializerContext.Default.Options); + var result = await function.InvokeAsync(arguments); + AssertExtensions.EqualFunctionCallResults(123.4, result); + } + + [Fact] + public async Task TypelessAIFunction_JsonDocumentValues_AcceptsArguments() + { + var arguments = JsonSerializer.Deserialize>(""" + { + "a": "string", + "b": 123.4, + "c": true, + "d": false, + "e": ["Monday", "Tuesday", "Wednesday"], + "f": null + } + """, TestJsonSerializerContext.Default.Options)!.ToDictionary(k => k.Key, k => (object?)k.Value); + + var result = await NetTypelessAIFunction.Instance.InvokeAsync(arguments); + Assert.Same(result, arguments); + } + + [Fact] + public async Task TypelessAIFunction_JsonElementValues_AcceptsArguments() + { + Dictionary arguments = JsonSerializer.Deserialize>(""" + { + "a": "string", + "b": 123.4, + "c": true, + "d": false, + "e": ["Monday", "Tuesday", "Wednesday"], + "f": null + } + """, TestJsonSerializerContext.Default.Options)!; + + var result = await NetTypelessAIFunction.Instance.InvokeAsync(arguments); + Assert.Same(result, arguments); + } + + [Fact] + public async Task TypelessAIFunction_JsonNodeValues_AcceptsArguments() + { + var arguments = JsonSerializer.Deserialize>(""" + { + "a": "string", + "b": 123.4, + "c": true, + "d": false, + "e": ["Monday", "Tuesday", "Wednesday"], + "f": null + } + """, TestJsonSerializerContext.Default.Options)!.ToDictionary(k => k.Key, k => (object?)k.Value); + + var result = await NetTypelessAIFunction.Instance.InvokeAsync(arguments); + Assert.Same(result, arguments); + } + + private sealed class CustomType; + + private sealed class NetTypelessAIFunction : AIFunction + { + public static NetTypelessAIFunction Instance { get; } = new NetTypelessAIFunction(); + + public override AIFunctionMetadata Metadata => new("NetTypeless") + { + Description = "AIFunction with parameters that lack .NET types", + Parameters = + [ + new AIFunctionParameterMetadata("a"), + new AIFunctionParameterMetadata("b"), + new AIFunctionParameterMetadata("c"), + new AIFunctionParameterMetadata("d"), + new AIFunctionParameterMetadata("e"), + new AIFunctionParameterMetadata("f"), + ] + }; + + protected override Task InvokeCoreAsync(IEnumerable>? arguments, CancellationToken cancellationToken) => + Task.FromResult(arguments); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs new file mode 100644 index 00000000000..a24120ca9a9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class FunctionResultContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + FunctionResultContent c = new("callId1", "functionName"); + Assert.Equal("callId1", c.CallId); + Assert.Equal("functionName", c.Name); + Assert.Null(c.RawRepresentation); + Assert.Null(c.ModelId); + Assert.Null(c.AdditionalProperties); + Assert.Null(c.Result); + Assert.Null(c.Exception); + } + + [Fact] + public void Constructor_String_PropsRoundtrip() + { + Exception e = new(); + + FunctionResultContent c = new("id", "name", "result", e); + Assert.Null(c.RawRepresentation); + Assert.Null(c.ModelId); + Assert.Null(c.AdditionalProperties); + Assert.Equal("name", c.Name); + Assert.Equal("id", c.CallId); + Assert.Equal("result", c.Result); + Assert.Same(e, c.Exception); + } + + [Fact] + public void Constructor_FunctionCallContent_PropsRoundtrip() + { + Exception e = new(); + + FunctionResultContent c = new(new FunctionCallContent("id", "name"), "result", e); + Assert.Null(c.RawRepresentation); + Assert.Null(c.ModelId); + Assert.Null(c.AdditionalProperties); + Assert.Equal("id", c.CallId); + Assert.Equal("result", c.Result); + Assert.Same(e, c.Exception); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + FunctionResultContent c = new("callId1", "functionName"); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.ModelId); + c.ModelId = "modelId"; + Assert.Equal("modelId", c.ModelId); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + + Assert.Equal("callId1", c.CallId); + c.CallId = "id"; + Assert.Equal("id", c.CallId); + + Assert.Null(c.Result); + c.Result = "result"; + Assert.Equal("result", c.Result); + + Assert.Null(c.Exception); + Exception e = new(); + c.Exception = e; + Assert.Same(e, c.Exception); + } + + [Fact] + public void ItShouldBeSerializableAndDeserializable() + { + // Arrange + var sut = new FunctionResultContent(new FunctionCallContent("id", "p1-f1"), "result"); + + // Act + var json = JsonSerializer.Serialize(sut, TestJsonSerializerContext.Default.Options); + + var deserializedSut = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + // Assert + Assert.NotNull(deserializedSut); + Assert.Equal(sut.Name, deserializedSut.Name); + Assert.Equal(sut.CallId, deserializedSut.CallId); + Assert.Equal(sut.Result, deserializedSut.Result?.ToString()); + } + + [Fact] + public void ItShouldBeSerializableAndDeserializableWithException() + { + // Arrange + var sut = new FunctionResultContent("callId1", "functionName") { Exception = new InvalidOperationException("hello") }; + + // Act + var json = JsonSerializer.Serialize(sut, TestJsonSerializerContext.Default.Options); + var deserializedSut = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + // Assert + Assert.NotNull(deserializedSut); + Assert.IsType(deserializedSut.Exception); + Assert.Contains("hello", deserializedSut.Exception.Message); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ImageContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ImageContentTests.cs new file mode 100644 index 00000000000..7b088e3ebf3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ImageContentTests.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public sealed class ImageContentTests : DataContentTests; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs new file mode 100644 index 00000000000..d1ba5e83bc9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class TextContentTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("text")] + public void Constructor_String_PropsDefault(string? text) + { + TextContent c = new(text); + Assert.Null(c.RawRepresentation); + Assert.Null(c.ModelId); + Assert.Null(c.AdditionalProperties); + Assert.Equal(text, c.Text); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + TextContent c = new(null); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.ModelId); + c.ModelId = "modelId"; + Assert.Equal("modelId", c.ModelId); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + + Assert.Null(c.Text); + c.Text = "text"; + Assert.Equal("text", c.Text); + Assert.Equal("text", c.ToString()); + + c.Text = null; + Assert.Null(c.Text); + Assert.Equal(string.Empty, c.ToString()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs new file mode 100644 index 00000000000..109bdc8120e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class UsageContentTests +{ + [Fact] + public void Constructor_InvalidArg_Throws() + { + Assert.Throws("details", () => new UsageContent(null!)); + } + + [Fact] + public void Constructor_Parameterless_PropsDefault() + { + UsageContent c = new(); + Assert.Null(c.RawRepresentation); + Assert.Null(c.ModelId); + Assert.Null(c.AdditionalProperties); + + Assert.NotNull(c.Details); + Assert.Same(c.Details, c.Details); + Assert.Null(c.Details.InputTokenCount); + Assert.Null(c.Details.OutputTokenCount); + Assert.Null(c.Details.TotalTokenCount); + Assert.Null(c.Details.AdditionalProperties); + } + + [Fact] + public void Constructor_UsageDetails_PropsRoundtrip() + { + UsageDetails details = new(); + + UsageContent c = new(details); + Assert.Null(c.RawRepresentation); + Assert.Null(c.ModelId); + Assert.Null(c.AdditionalProperties); + + Assert.Same(details, c.Details); + + UsageDetails details2 = new(); + c.Details = details2; + Assert.Same(details2, c.Details); + } + + [Fact] + public void Details_SetNull_Throws() + { + UsageContent c = new(); + + UsageDetails d = c.Details; + Assert.NotNull(d); + + Assert.Throws("value", () => c.Details = null!); + + Assert.Same(d, c.Details); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs new file mode 100644 index 00000000000..91640e62f4f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DelegatingEmbeddingGeneratorTests +{ + [Fact] + public void RequiresInnerService() + { + Assert.Throws(() => new NoOpDelegatingEmbeddingGenerator(null!)); + } + + [Fact] + public void MetadataDefaultsToInnerService() + { + using var inner = new TestEmbeddingGenerator(); + using var delegating = new NoOpDelegatingEmbeddingGenerator(inner); + + Assert.Same(inner.Metadata, delegating.Metadata); + } + + [Fact] + public async Task GenerateEmbeddingsDefaultsToInnerServiceAsync() + { + // Arrange + var expectedInput = new List(); + using var cts = new CancellationTokenSource(); + var expectedCancellationToken = cts.Token; + var expectedResult = new TaskCompletionSource>>(); + var expectedEmbedding = new GeneratedEmbeddings>([new(new float[] { 1.0f, 2.0f, 3.0f })]); + using var inner = new TestEmbeddingGenerator + { + GenerateAsyncCallback = (input, options, cancellationToken) => + { + Assert.Same(expectedInput, input); + Assert.Equal(expectedCancellationToken, cancellationToken); + return expectedResult.Task; + } + }; + + using var delegating = new NoOpDelegatingEmbeddingGenerator(inner); + + // Act + var resultTask = delegating.GenerateAsync(expectedInput, options: null, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(expectedEmbedding); + Assert.True(resultTask.IsCompleted); + Assert.Same(expectedEmbedding, await resultTask); + } + + [Fact] + public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() + { + // Arrange + using var inner = new TestEmbeddingGenerator(); + using var delegating = new NoOpDelegatingEmbeddingGenerator(inner); + + // Act + var service = delegating.GetService>>(); + + // Assert + Assert.Same(delegating, service); + } + + [Fact] + public void GetServiceDelegatesToInnerIfKeyIsNotNull() + { + // Arrange + var expectedParam = new object(); + var expectedKey = new object(); + using var expectedResult = new TestEmbeddingGenerator(); + using var inner = new TestEmbeddingGenerator + { + GetServiceCallback = (_, _) => expectedResult + }; + using var delegating = new NoOpDelegatingEmbeddingGenerator(inner); + + // Act + var service = delegating.GetService>>(expectedKey); + + // Assert + Assert.Same(expectedResult, service); + } + + [Fact] + public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest() + { + // Arrange + var expectedParam = new object(); + var expectedResult = TimeZoneInfo.Local; + var expectedKey = new object(); + using var inner = new TestEmbeddingGenerator + { + GetServiceCallback = (type, key) => type == expectedResult.GetType() && key == expectedKey + ? expectedResult + : throw new InvalidOperationException("Unexpected call") + }; + using var delegating = new NoOpDelegatingEmbeddingGenerator(inner); + + // Act + var service = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, service); + } + + private sealed class NoOpDelegatingEmbeddingGenerator(IEmbeddingGenerator> innerGenerator) : + DelegatingEmbeddingGenerator>(innerGenerator); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs new file mode 100644 index 00000000000..e9dd45959c7 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs @@ -0,0 +1,70 @@ +// 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; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class EmbeddingGenerationOptionsTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + EmbeddingGenerationOptions options = new(); + Assert.Null(options.ModelId); + Assert.Null(options.AdditionalProperties); + + EmbeddingGenerationOptions clone = options.Clone(); + Assert.Null(clone.ModelId); + Assert.Null(clone.AdditionalProperties); + } + + [Fact] + public void Properties_Roundtrip() + { + EmbeddingGenerationOptions options = new(); + + AdditionalPropertiesDictionary additionalProps = new() + { + ["key"] = "value", + }; + + options.ModelId = "modelId"; + options.AdditionalProperties = additionalProps; + + Assert.Equal("modelId", options.ModelId); + Assert.Same(additionalProps, options.AdditionalProperties); + + EmbeddingGenerationOptions clone = options.Clone(); + Assert.Equal("modelId", clone.ModelId); + Assert.Equal(additionalProps, clone.AdditionalProperties); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + EmbeddingGenerationOptions options = new(); + + AdditionalPropertiesDictionary additionalProps = new() + { + ["key"] = "value", + }; + + options.ModelId = "model"; + options.AdditionalProperties = additionalProps; + + string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.EmbeddingGenerationOptions); + + EmbeddingGenerationOptions? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.EmbeddingGenerationOptions); + Assert.NotNull(deserialized); + + Assert.Equal("model", deserialized.ModelId); + + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Single(deserialized.AdditionalProperties); + Assert.True(deserialized.AdditionalProperties.TryGetValue("key", out object? value)); + Assert.IsType(value); + Assert.Equal("value", ((JsonElement)value!).GetString()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs new file mode 100644 index 00000000000..827ed04c712 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class EmbeddingGeneratorExtensionsTests +{ + [Fact] + public async Task GenerateAsync_InvalidArgs_ThrowsAsync() + { + await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateAsync("hello")); + } + + [Fact] + public async Task GenerateAsync_ReturnsSingleEmbeddingAsync() + { + Embedding result = new(new float[] { 1f, 2f, 3f }); + + using TestEmbeddingGenerator service = new() + { + GenerateAsyncCallback = (values, options, cancellationToken) => + Task.FromResult>>([result]) + }; + + Assert.Same(result, (await service.GenerateAsync("hello"))[0]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorMetadataTests.cs new file mode 100644 index 00000000000..b3cd0d59abb --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorMetadataTests.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class EmbeddingGeneratorMetadataTests +{ + [Fact] + public void Constructor_NullValues_AllowedAndRoundtrip() + { + EmbeddingGeneratorMetadata metadata = new(null, null, null, null); + Assert.Null(metadata.ProviderName); + Assert.Null(metadata.ProviderUri); + Assert.Null(metadata.ModelId); + Assert.Null(metadata.Dimensions); + } + + [Fact] + public void Constructor_Value_Roundtrips() + { + var uri = new Uri("https://example.com"); + EmbeddingGeneratorMetadata metadata = new("providerName", uri, "theModel", 42); + Assert.Equal("providerName", metadata.ProviderName); + Assert.Same(uri, metadata.ProviderUri); + Assert.Equal("theModel", metadata.ModelId); + Assert.Equal(42, metadata.Dimensions); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingTests.cs new file mode 100644 index 00000000000..45fcce8ba63 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingTests.cs @@ -0,0 +1,78 @@ +// 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.Runtime.InteropServices; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class EmbeddingTests +{ + [Fact] + public void Embedding_Ctor_Roundtrips() + { + float[] floats = [1f, 2f, 3f]; + UsageDetails usage = new(); + AdditionalPropertiesDictionary props = []; + var createdAt = DateTimeOffset.Parse("2022-01-01T00:00:00Z"); + const string Model = "text-embedding-3-small"; + + Embedding e = new(floats) + { + CreatedAt = createdAt, + ModelId = Model, + AdditionalProperties = props, + }; + + Assert.Equal(floats, e.Vector.ToArray()); + Assert.Equal(Model, e.ModelId); + Assert.Same(props, e.AdditionalProperties); + Assert.Equal(createdAt, e.CreatedAt); + + Assert.True(MemoryMarshal.TryGetArray(e.Vector, out ArraySegment array)); + Assert.Same(floats, array.Array); + } + +#if NET + [Fact] + public void Embedding_Half_SerializationRoundtrips() + { + Half[] halfs = [(Half)1f, (Half)2f, (Half)3f]; + Embedding e = new(halfs); + + string json = JsonSerializer.Serialize(e, TestJsonSerializerContext.Default.Embedding); + Assert.Equal("""{"$type":"halves","vector":[1,2,3]}""", json); + + Embedding result = Assert.IsType>(JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Embedding)); + Assert.Equal(e.Vector.ToArray(), result.Vector.ToArray()); + } +#endif + + [Fact] + public void Embedding_Single_SerializationRoundtrips() + { + float[] floats = [1f, 2f, 3f]; + Embedding e = new(floats); + + string json = JsonSerializer.Serialize(e, TestJsonSerializerContext.Default.Embedding); + Assert.Equal("""{"$type":"floats","vector":[1,2,3]}""", json); + + Embedding result = Assert.IsType>(JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Embedding)); + Assert.Equal(e.Vector.ToArray(), result.Vector.ToArray()); + } + + [Fact] + public void Embedding_Double_SerializationRoundtrips() + { + double[] floats = [1f, 2f, 3f]; + Embedding e = new(floats); + + string json = JsonSerializer.Serialize(e, TestJsonSerializerContext.Default.Embedding); + Assert.Equal("""{"$type":"doubles","vector":[1,2,3]}""", json); + + Embedding result = Assert.IsType>(JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Embedding)); + Assert.Equal(e.Vector.ToArray(), result.Vector.ToArray()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs new file mode 100644 index 00000000000..4ebd9465ca8 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs @@ -0,0 +1,246 @@ +// 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 Xunit; + +#pragma warning disable xUnit2013 // Do not use equality check to check for collection size. +#pragma warning disable xUnit2017 // Do not use Contains() to check if a value exists in a collection + +namespace Microsoft.Extensions.AI; + +public class GeneratedEmbeddingsTests +{ + [Fact] + public void Ctor_InvalidArgs_Throws() + { + Assert.Throws("embeddings", () => new GeneratedEmbeddings>(null!)); + Assert.Throws("capacity", () => new GeneratedEmbeddings>(-1)); + } + + [Fact] + public void Ctor_ValidArgs_NoExceptions() + { + GeneratedEmbeddings>[] instances = + [ + [], + new(0), + new(42), + new([]) + ]; + + foreach (var instance in instances) + { + Assert.Empty(instance); + + Assert.False(((ICollection>)instance).IsReadOnly); + Assert.Equal(0, instance.Count); + + Assert.False(instance.Contains(new Embedding(new float[] { 1, 2, 3 }))); + Assert.False(instance.Contains(null!)); + + Assert.Equal(-1, instance.IndexOf(new Embedding(new float[] { 1, 2, 3 }))); + Assert.Equal(-1, instance.IndexOf(null!)); + + instance.CopyTo(Array.Empty>(), 0); + + Assert.Throws(() => instance[0]); + Assert.Throws(() => instance[-1]); + } + } + + [Fact] + public void Ctor_RoundtripsEnumerable() + { + List> embeddings = + [ + new(new float[] { 1, 2, 3 }), + new(new float[] { 4, 5, 6 }), + ]; + + var generatedEmbeddings = new GeneratedEmbeddings>(embeddings); + + Assert.Equal(embeddings, generatedEmbeddings); + Assert.Equal(2, generatedEmbeddings.Count); + + Assert.Same(embeddings[0], generatedEmbeddings[0]); + Assert.Same(embeddings[1], generatedEmbeddings[1]); + + Assert.Equal(0, generatedEmbeddings.IndexOf(embeddings[0])); + Assert.Equal(1, generatedEmbeddings.IndexOf(embeddings[1])); + + Assert.True(generatedEmbeddings.Contains(embeddings[0])); + Assert.True(generatedEmbeddings.Contains(embeddings[1])); + + Assert.False(generatedEmbeddings.Contains(null!)); + Assert.Equal(-1, generatedEmbeddings.IndexOf(null!)); + + Assert.Throws(() => generatedEmbeddings[-1]); + Assert.Throws(() => generatedEmbeddings[2]); + + Assert.True(embeddings.SequenceEqual(generatedEmbeddings)); + + var e = new Embedding(new float[] { 7, 8, 9 }); + generatedEmbeddings.Add(e); + Assert.Equal(3, generatedEmbeddings.Count); + Assert.Same(e, generatedEmbeddings[2]); + } + + [Fact] + public void Properties_Roundtrip() + { + GeneratedEmbeddings> embeddings = []; + + Assert.Null(embeddings.Usage); + + UsageDetails usage = new(); + embeddings.Usage = usage; + Assert.Same(usage, embeddings.Usage); + embeddings.Usage = null; + Assert.Null(embeddings.Usage); + + Assert.Null(embeddings.AdditionalProperties); + AdditionalPropertiesDictionary props = []; + embeddings.AdditionalProperties = props; + Assert.Same(props, embeddings.AdditionalProperties); + embeddings.AdditionalProperties = null; + Assert.Null(embeddings.AdditionalProperties); + } + + [Fact] + public void Add() + { + GeneratedEmbeddings> embeddings = []; + var e = new Embedding(new float[] { 1, 2, 3 }); + + embeddings.Add(e); + Assert.Equal(1, embeddings.Count); + Assert.Same(e, embeddings[0]); + } + + [Fact] + public void AddRange() + { + GeneratedEmbeddings> embeddings = []; + + var e1 = new Embedding(new float[] { 1, 2, 3 }); + var e2 = new Embedding(new float[] { 4, 5, 6 }); + + embeddings.AddRange(new[] { e1, e2 }); + + Assert.Equal(2, embeddings.Count); + Assert.Same(e1, embeddings[0]); + Assert.Same(e2, embeddings[1]); + } + + [Fact] + public void Clear() + { + GeneratedEmbeddings> embeddings = []; + + var e1 = new Embedding(new float[] { 1, 2, 3 }); + var e2 = new Embedding(new float[] { 4, 5, 6 }); + + embeddings.AddRange(new[] { e1, e2 }); + Assert.Equal(2, embeddings.Count); + + embeddings.Clear(); + Assert.Equal(0, embeddings.Count); + Assert.Empty(embeddings); + } + + [Fact] + public void Remove() + { + GeneratedEmbeddings> embeddings = []; + + var e1 = new Embedding(new float[] { 1, 2, 3 }); + var e2 = new Embedding(new float[] { 4, 5, 6 }); + + embeddings.AddRange(new[] { e1, e2 }); + Assert.Equal(2, embeddings.Count); + + Assert.True(embeddings.Remove(e1)); + Assert.Equal(1, embeddings.Count); + Assert.Same(e2, embeddings[0]); + + Assert.False(embeddings.Remove(e1)); + Assert.Equal(1, embeddings.Count); + Assert.Same(e2, embeddings[0]); + + Assert.True(embeddings.Remove(e2)); + Assert.Equal(0, embeddings.Count); + } + + [Fact] + public void RemoveAt() + { + GeneratedEmbeddings> embeddings = []; + + var e1 = new Embedding(new float[] { 1, 2, 3 }); + var e2 = new Embedding(new float[] { 4, 5, 6 }); + + embeddings.AddRange(new[] { e1, e2 }); + Assert.Equal(2, embeddings.Count); + + embeddings.RemoveAt(0); + Assert.Equal(1, embeddings.Count); + Assert.Same(e2, embeddings[0]); + + embeddings.RemoveAt(0); + Assert.Equal(0, embeddings.Count); + } + + [Fact] + public void Insert() + { + GeneratedEmbeddings> embeddings = []; + + var e1 = new Embedding(new float[] { 1, 2, 3 }); + var e2 = new Embedding(new float[] { 4, 5, 6 }); + + embeddings.AddRange(new[] { e1, e2 }); + Assert.Equal(2, embeddings.Count); + + var e3 = new Embedding(new float[] { 7, 8, 9 }); + embeddings.Insert(1, e3); + Assert.Equal(3, embeddings.Count); + Assert.Same(e3, embeddings[1]); + Assert.Same(e2, embeddings[2]); + } + + [Fact] + public void Indexer() + { + GeneratedEmbeddings> embeddings = []; + + var e1 = new Embedding(new float[] { 1, 2, 3 }); + var e2 = new Embedding(new float[] { 4, 5, 6 }); + + embeddings.AddRange(new[] { e1, e2 }); + Assert.Equal(2, embeddings.Count); + + var e3 = new Embedding(new float[] { 7, 8, 9 }); + embeddings[1] = e3; + Assert.Equal(2, embeddings.Count); + Assert.Same(e1, embeddings[0]); + Assert.Same(e3, embeddings[1]); + } + + [Fact] + public void Indexer_InvalidIndex_Throws() + { + GeneratedEmbeddings> embeddings = []; + + var e1 = new Embedding(new float[] { 1, 2, 3 }); + var e2 = new Embedding(new float[] { 4, 5, 6 }); + + embeddings.AddRange(new[] { e1, e2 }); + Assert.Equal(2, embeddings.Count); + + Assert.Throws(() => embeddings[-1]); + Assert.Throws(() => embeddings[2]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionMetadataTests.cs new file mode 100644 index 00000000000..a1aa48bd115 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionMetadataTests.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIFunctionMetadataTests +{ + [Fact] + public void Constructor_InvalidArg_Throws() + { + Assert.Throws("name", () => new AIFunctionMetadata((string)null!)); + Assert.Throws("name", () => new AIFunctionMetadata(" \t ")); + Assert.Throws("metadata", () => new AIFunctionMetadata((AIFunctionMetadata)null!)); + } + + [Fact] + public void Constructor_String_PropsDefaulted() + { + AIFunctionMetadata f = new("name"); + Assert.Equal("name", f.Name); + Assert.Empty(f.Description); + Assert.Empty(f.Parameters); + + Assert.NotNull(f.ReturnParameter); + Assert.Null(f.ReturnParameter.Schema); + Assert.Null(f.ReturnParameter.ParameterType); + Assert.Null(f.ReturnParameter.Description); + + Assert.NotNull(f.AdditionalProperties); + Assert.Empty(f.AdditionalProperties); + Assert.Same(f.AdditionalProperties, new AIFunctionMetadata("name2").AdditionalProperties); + } + + [Fact] + public void Constructor_Copy_PropsPropagated() + { + AIFunctionMetadata f1 = new("name") + { + Description = "description", + Parameters = [new AIFunctionParameterMetadata("param")], + ReturnParameter = new AIFunctionReturnParameterMetadata(), + AdditionalProperties = new Dictionary { { "key", "value" } }, + }; + + AIFunctionMetadata f2 = new(f1); + Assert.Equal(f1.Name, f2.Name); + Assert.Equal(f1.Description, f2.Description); + Assert.Same(f1.Parameters, f2.Parameters); + Assert.Same(f1.ReturnParameter, f2.ReturnParameter); + Assert.Same(f1.AdditionalProperties, f2.AdditionalProperties); + } + + [Fact] + public void Props_InvalidArg_Throws() + { + Assert.Throws("value", () => new AIFunctionMetadata("name") { Name = null! }); + Assert.Throws("value", () => new AIFunctionMetadata("name") { Parameters = null! }); + Assert.Throws("value", () => new AIFunctionMetadata("name") { ReturnParameter = null! }); + Assert.Throws("value", () => new AIFunctionMetadata("name") { AdditionalProperties = null! }); + } + + [Fact] + public void Description_NullNormalizedToEmpty() + { + AIFunctionMetadata f = new("name") { Description = null }; + Assert.Equal("", f.Description); + } + + [Fact] + public void GetParameter_EmptyCollection_ReturnsNull() + { + Assert.Null(new AIFunctionMetadata("name").GetParameter("test")); + } + + [Fact] + public void GetParameter_ByName_ReturnsParameter() + { + AIFunctionMetadata f = new("name") + { + Parameters = + [ + new AIFunctionParameterMetadata("param0"), + new AIFunctionParameterMetadata("param1"), + new AIFunctionParameterMetadata("param2"), + ] + }; + + Assert.Same(f.Parameters[0], f.GetParameter("param0")); + Assert.Same(f.Parameters[1], f.GetParameter("param1")); + Assert.Same(f.Parameters[2], f.GetParameter("param2")); + Assert.Null(f.GetParameter("param3")); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionParameterMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionParameterMetadataTests.cs new file mode 100644 index 00000000000..23c33ecf07a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionParameterMetadataTests.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIFunctionParameterMetadataTests +{ + [Fact] + public void Constructor_InvalidArg_Throws() + { + Assert.Throws("name", () => new AIFunctionParameterMetadata((string)null!)); + Assert.Throws("name", () => new AIFunctionParameterMetadata(" ")); + Assert.Throws("metadata", () => new AIFunctionParameterMetadata((AIFunctionParameterMetadata)null!)); + } + + [Fact] + public void Constructor_String_PropsDefaulted() + { + AIFunctionParameterMetadata p = new("name"); + Assert.Equal("name", p.Name); + Assert.Null(p.Description); + Assert.Null(p.DefaultValue); + Assert.False(p.IsRequired); + Assert.Null(p.ParameterType); + Assert.Null(p.Schema); + } + + [Fact] + public void Constructor_Copy_PropsPropagated() + { + AIFunctionParameterMetadata p1 = new("name") + { + Description = "description", + HasDefaultValue = true, + DefaultValue = 42, + IsRequired = true, + ParameterType = typeof(int), + Schema = JsonDocument.Parse("""{"type":"integer"}"""), + }; + + AIFunctionParameterMetadata p2 = new(p1); + + Assert.Equal(p1.Name, p2.Name); + Assert.Equal(p1.Description, p2.Description); + Assert.Equal(p1.DefaultValue, p2.DefaultValue); + Assert.Equal(p1.IsRequired, p2.IsRequired); + Assert.Equal(p1.ParameterType, p2.ParameterType); + Assert.Equal(p1.Schema, p2.Schema); + } + + [Fact] + public void Constructor_Copy_PropsPropagatedAndOverwritten() + { + AIFunctionParameterMetadata p1 = new("name") + { + Description = "description", + HasDefaultValue = true, + DefaultValue = 42, + IsRequired = true, + ParameterType = typeof(int), + Schema = JsonDocument.Parse("""{"type":"integer"}"""), + }; + + AIFunctionParameterMetadata p2 = new(p1) + { + Description = "description2", + HasDefaultValue = true, + DefaultValue = 43, + IsRequired = false, + ParameterType = typeof(long), + Schema = JsonDocument.Parse("""{"type":"number"}"""), + }; + + Assert.Equal("description2", p2.Description); + Assert.True(p2.HasDefaultValue); + Assert.Equal(43, p2.DefaultValue); + Assert.False(p2.IsRequired); + Assert.Equal(typeof(long), p2.ParameterType); + } + + [Fact] + public void Props_InvalidArg_Throws() + { + Assert.Throws("value", () => new AIFunctionMetadata("name") { Name = null! }); + Assert.Throws("value", () => new AIFunctionMetadata("name") { Name = "\r\n\t " }); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionReturnParameterMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionReturnParameterMetadataTests.cs new file mode 100644 index 00000000000..bb5bbeec03a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionReturnParameterMetadataTests.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIFunctionReturnParameterMetadataTests +{ + [Fact] + public void Constructor_PropsDefaulted() + { + AIFunctionReturnParameterMetadata p = new(); + Assert.Null(p.Description); + Assert.Null(p.ParameterType); + Assert.Null(p.Schema); + } + + [Fact] + public void Constructor_Copy_PropsPropagated() + { + AIFunctionReturnParameterMetadata p1 = new() + { + Description = "description", + ParameterType = typeof(int), + Schema = JsonDocument.Parse("""{"type":"integer"}"""), + }; + + AIFunctionReturnParameterMetadata p2 = new(p1); + Assert.Equal(p1.Description, p2.Description); + Assert.Equal(p1.ParameterType, p2.ParameterType); + Assert.Equal(p1.Schema, p2.Schema); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionTests.cs new file mode 100644 index 00000000000..df143e8b97e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AIFunctionTests.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIFunctionTests +{ + [Fact] + public async Task InvokeAsync_UsesDefaultEmptyCollectionForNullArgsAsync() + { + DerivedAIFunction f = new(); + + using CancellationTokenSource cts = new(); + var result1 = ((IEnumerable>, CancellationToken))(await f.InvokeAsync(null, cts.Token))!; + + Assert.NotNull(result1.Item1); + Assert.Empty(result1.Item1); + Assert.Equal(cts.Token, result1.Item2); + + var result2 = ((IEnumerable>, CancellationToken))(await f.InvokeAsync(null, cts.Token))!; + Assert.Same(result1.Item1, result2.Item1); + } + + [Fact] + public void ToString_ReturnsName() + { + DerivedAIFunction f = new(); + Assert.Equal("name", f.ToString()); + } + + private sealed class DerivedAIFunction : AIFunction + { + public override AIFunctionMetadata Metadata => new("name"); + + protected override Task InvokeCoreAsync(IEnumerable> arguments, CancellationToken cancellationToken) + { + Assert.NotNull(arguments); + return Task.FromResult((arguments, cancellationToken)); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj new file mode 100644 index 00000000000..0d4d5fbfa96 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -0,0 +1,24 @@ + + + Microsoft.Extensions.AI + Unit tests for Microsoft.Extensions.AI.Abstractions. + + + + $(NoWarn);CA1063;CA1861;CA2201;VSTHRD003 + true + + + + true + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs new file mode 100644 index 00000000000..55f4c486483 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +public sealed class TestChatClient : IChatClient +{ + public IServiceProvider? Services { get; set; } + + public ChatClientMetadata Metadata { get; set; } = new(); + + public Func, ChatOptions?, CancellationToken, Task>? CompleteAsyncCallback { get; set; } + + public Func, ChatOptions?, CancellationToken, IAsyncEnumerable>? CompleteStreamingAsyncCallback { get; set; } + + public Func? GetServiceCallback { get; set; } + + public Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => CompleteAsyncCallback!.Invoke(chatMessages, options, cancellationToken); + + public IAsyncEnumerable CompleteStreamingAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => CompleteStreamingAsyncCallback!.Invoke(chatMessages, options, cancellationToken); + + public TService? GetService(object? key = null) + where TService : class + => (TService?)GetServiceCallback!(typeof(TService), key); + + void IDisposable.Dispose() + { + // No resources need disposing. + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs new file mode 100644 index 00000000000..83680a2be10 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +public sealed class TestEmbeddingGenerator : IEmbeddingGenerator> +{ + public EmbeddingGeneratorMetadata Metadata { get; } = new(); + + public Func, EmbeddingGenerationOptions?, CancellationToken, Task>>>? GenerateAsyncCallback { get; set; } + + public Func? GetServiceCallback { get; set; } + + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + => GenerateAsyncCallback!.Invoke(values, options, cancellationToken); + + public TService? GetService(object? key = null) + where TService : class + => (TService?)GetServiceCallback!(typeof(TService), key); + + void IDisposable.Dispose() + { + // No resources to dispose + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs new file mode 100644 index 00000000000..5a3e966c17b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -0,0 +1,31 @@ +// 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.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(ChatCompletion))] +[JsonSerializable(typeof(StreamingChatCompletionUpdate))] +[JsonSerializable(typeof(ChatOptions))] +[JsonSerializable(typeof(EmbeddingGenerationOptions))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(int[]))] // Used in ChatMessageContentTests +[JsonSerializable(typeof(Embedding))] // Used in EmbeddingTests +[JsonSerializable(typeof(Dictionary))] // Used in Content tests +[JsonSerializable(typeof(Dictionary))] // Used in Content tests +[JsonSerializable(typeof(Dictionary))] // Used in Content tests +[JsonSerializable(typeof(ReadOnlyDictionary))] // Used in Content tests +[JsonSerializable(typeof(DayOfWeek[]))] // Used in Content tests +[JsonSerializable(typeof(Guid))] // Used in Content tests +[JsonSerializable(typeof(decimal))] // Used in Content tests +internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs new file mode 100644 index 00000000000..29aef62fd77 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs @@ -0,0 +1,18 @@ +// 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; + +namespace Microsoft.Extensions.AI; + +public class AzureAIInferenceChatClientIntegrationTests : ChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetChatCompletionsClient() + ?.AsChatClient(Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_CHAT_MODEL") ?? "gpt-4o-mini"); + + public override Task CompleteStreamingAsync_UsageDataAvailable() => + throw new SkipTestException("Azure.AI.Inference library doesn't currently surface streaming usage data."); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs new file mode 100644 index 00000000000..fd4bd11a96f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -0,0 +1,536 @@ +// 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.Threading.Tasks; +using Azure; +using Azure.AI.Inference; +using Azure.Core.Pipeline; +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 AzureAIInferenceChatClientTests +{ + [Fact] + public void Ctor_InvalidArgs_Throws() + { + Assert.Throws("chatCompletionsClient", () => new AzureAIInferenceChatClient(null!, "model")); + + ChatCompletionsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); + Assert.Throws("modelId", () => new AzureAIInferenceChatClient(client, " ")); + } + + [Fact] + public void AsChatClient_InvalidArgs_Throws() + { + Assert.Throws("chatCompletionsClient", () => ((ChatCompletionsClient)null!).AsChatClient("model")); + + ChatCompletionsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); + Assert.Throws("modelId", () => client.AsChatClient(" ")); + } + + [Fact] + public void AsChatClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + ChatCompletionsClient client = new(endpoint, new AzureKeyCredential("key")); + + IChatClient chatClient = client.AsChatClient(model); + Assert.Equal("AzureAIInference", chatClient.Metadata.ProviderName); + Assert.Equal(endpoint, chatClient.Metadata.ProviderUri); + Assert.Equal(model, chatClient.Metadata.ModelId); + } + + [Fact] + public void GetService_SuccessfullyReturnsUnderlyingClient() + { + ChatCompletionsClient client = new(new("http://localhost"), new AzureKeyCredential("key")); + IChatClient chatClient = client.AsChatClient("model"); + + Assert.Same(chatClient, chatClient.GetService()); + Assert.Same(chatClient, chatClient.GetService()); + + Assert.Same(client, chatClient.GetService()); + + using IChatClient pipeline = new ChatClientBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Use(chatClient); + + 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 async Task BasicRequestResponse_NonStreaming() + { + const string Input = """ + {"messages":[{"content":[{"text":"hello","type":"text"}],"role":"user"}],"max_tokens":10,"temperature":0.5,"model":"gpt-4o-mini"} + """; + + 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" + } + ], + "usage": { + "prompt_tokens": 8, + "completion_tokens": 9, + "total_tokens": 17, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.CompleteAsync("hello", new() + { + MaxOutputTokens = 10, + Temperature = 0.5f, + }); + Assert.NotNull(response); + + Assert.Equal("chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", response.CompletionId); + Assert.Equal("Hello! How can I assist you today?", response.Message.Text); + Assert.Single(response.Message.Contents); + Assert.Equal(ChatRole.Assistant, response.Message.Role); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_727_888_631), response.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + + Assert.NotNull(response.Usage); + Assert.Equal(8, response.Usage.InputTokenCount); + Assert.Equal(9, response.Usage.OutputTokenCount); + Assert.Equal(17, response.Usage.TotalTokenCount); + } + + [Fact] + public async Task BasicRequestResponse_Streaming() + { + const string Input = """ + {"messages":[{"content":[{"text":"hello","type":"text"}],"role":"user"}],"max_tokens":20,"temperature":0.5,"stream":true,"model":"gpt-4o-mini"} + """; + + const string Output = """ + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" How"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" can"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" assist"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" today"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":8,"completion_tokens":9,"total_tokens":17,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}} + + data: [DONE] + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.CompleteStreamingAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + })) + { + updates.Add(update); + } + + Assert.Equal("Hello! How can I assist you today?", string.Concat(updates.Select(u => u.Text))); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_727_889_370); + Assert.Equal(12, updates.Count); + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK", updates[i].CompletionId); + Assert.Equal(createdAt, updates[i].CreatedAt); + Assert.All(updates[i].Contents, u => Assert.Equal("gpt-4o-mini-2024-07-18", u.ModelId)); + Assert.Equal(ChatRole.Assistant, updates[i].Role); + Assert.Equal(i < 10 ? 1 : 0, updates[i].Contents.Count); + Assert.Equal(i < 10 ? null : ChatFinishReason.Stop, updates[i].FinishReason); + } + } + + [Fact] + public async Task MultipleMessages_NonStreaming() + { + const string Input = """ + { + "messages": [ + { + "content": "You are a really nice friend.", + "role": "system" + }, + { + "content": [ + { + "text": "hello!", + "type": "text" + } + ], + "role": "user" + }, + { + "content": "hi, how are you?", + "role": "assistant" + }, + { + "content": [ + { + "text": "i\u0027m good. how are you?", + "type": "text" + } + ], + "role": "user" + } + ], + "temperature": 0.25, + "stop": [ + "great" + ], + "presence_penalty": 0.5, + "frequency_penalty": 0.75, + "model": "gpt-4o-mini", + "seed": 42 + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I’m doing well, thank you! What’s on your mind today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 42, + "completion_tokens": 15, + "total_tokens": 57, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List messages = + [ + new(ChatRole.System, "You are a really nice friend."), + new(ChatRole.User, "hello!"), + new(ChatRole.Assistant, "hi, how are you?"), + new(ChatRole.User, "i'm good. how are you?"), + ]; + + var response = await client.CompleteAsync(messages, new() + { + Temperature = 0.25f, + FrequencyPenalty = 0.75f, + PresencePenalty = 0.5f, + StopSequences = ["great"], + AdditionalProperties = new() { ["seed"] = 42L }, + }); + Assert.NotNull(response); + + Assert.Equal("chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", response.CompletionId); + Assert.Equal("I’m doing well, thank you! What’s on your mind today?", response.Message.Text); + Assert.Single(response.Message.Contents); + Assert.Equal(ChatRole.Assistant, response.Message.Role); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_727_894_187), response.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + + Assert.NotNull(response.Usage); + Assert.Equal(42, response.Usage.InputTokenCount); + Assert.Equal(15, response.Usage.OutputTokenCount); + Assert.Equal(57, response.Usage.TotalTokenCount); + } + + [Fact] + public async Task FunctionCallContent_NonStreaming() + { + const string Input = """ + { + "messages": [ + { + "content": [ + { + "text": "How old is Alice?", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "gpt-4o-mini", + "tools": [ + { + "function": { + "name": "GetPersonAge", + "description": "Gets the age of the specified person.", + "parameters": { + "type": "object", + "required": ["personName"], + "properties": { + "personName": { + "description": "The person whose age is being requested", + "type": "string" + } + } + } + }, + "type": "function" + } + ], + "tool_choice": "auto" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADydKhrSKEBWJ8gy0KCIU74rN3Hmk", + "object": "chat.completion", + "created": 1727894702, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_8qbINM045wlmKZt9bVJgwAym", + "type": "function", + "function": { + "name": "GetPersonAge", + "arguments": "{\"personName\":\"Alice\"}" + } + } + ], + "refusal": null + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 61, + "completion_tokens": 16, + "total_tokens": 77, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.CompleteAsync("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.Null(response.Message.Text); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(ChatRole.Assistant, response.Message.Role); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_727_894_702), response.CreatedAt); + Assert.Equal(ChatFinishReason.ToolCalls, response.FinishReason); + Assert.NotNull(response.Usage); + Assert.Equal(61, response.Usage.InputTokenCount); + Assert.Equal(16, response.Usage.OutputTokenCount); + Assert.Equal(77, response.Usage.TotalTokenCount); + + Assert.Single(response.Choices); + Assert.Single(response.Message.Contents); + FunctionCallContent fcc = Assert.IsType(response.Message.Contents[0]); + Assert.Equal("GetPersonAge", fcc.Name); + AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); + } + + [Fact] + public async Task FunctionCallContent_Streaming() + { + const string Input = """ + { + "messages": [ + { + "content": [ + { + "text": "How old is Alice?", + "type": "text" + } + ], + "role": "user" + } + ], + "stream": true, + "model": "gpt-4o-mini", + "tools": [ + { + "function": { + "name": "GetPersonAge", + "description": "Gets the age of the specified person.", + "parameters": { + "type": "object", + "required": ["personName"], + "properties": { + "personName": { + "description": "The person whose age is being requested", + "type": "string" + } + } + } + }, + "type": "function" + } + ], + "tool_choice": "auto" + } + """; + + const string Output = """ + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_F9ZaqPWo69u0urxAhVt8meDW","type":"function","function":{"name":"GetPersonAge","arguments":""}}],"refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"person"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Name"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Alice"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":61,"completion_tokens":16,"total_tokens":77,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}} + + data: [DONE] + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.CompleteStreamingAsync("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.")], + })) + { + updates.Add(update); + } + + Assert.Equal("", string.Concat(updates.Select(u => u.Text))); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_727_895_263); + Assert.Equal(10, updates.Count); + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl", updates[i].CompletionId); + Assert.Equal(createdAt, updates[i].CreatedAt); + Assert.All(updates[i].Contents, u => Assert.Equal("gpt-4o-mini-2024-07-18", u.ModelId)); + Assert.Equal(ChatRole.Assistant, updates[i].Role); + Assert.Equal(i < 7 ? null : ChatFinishReason.ToolCalls, updates[i].FinishReason); + } + + FunctionCallContent fcc = Assert.IsType(Assert.Single(updates[updates.Count - 1].Contents)); + Assert.Equal("call_F9ZaqPWo69u0urxAhVt8meDW", fcc.CallId); + Assert.Equal("GetPersonAge", fcc.Name); + AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); + } + + private static IChatClient CreateChatClient(HttpClient httpClient, string modelId) => + new ChatCompletionsClient( + new("http://somewhere"), + new AzureKeyCredential("key"), + new ChatCompletionsClientOptions { Transport = new HttpClientTransport(httpClient) }) + .AsChatClient(modelId); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs new file mode 100644 index 00000000000..4c4086e1157 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs @@ -0,0 +1,31 @@ +// 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 Azure; +using Azure.AI.Inference; + +namespace Microsoft.Extensions.AI; + +/// Shared utility methods for integration tests. +internal static class IntegrationTestHelpers +{ + /// Gets an to use for testing, or null if the associated tests should be disabled. + public static ChatCompletionsClient? GetChatCompletionsClient() + { + string? apiKey = + Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_APIKEY") ?? + Environment.GetEnvironmentVariable("OPENAI_API_KEY"); + + if (apiKey is not null) + { + string? endpoint = + Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_ENDPOINT") ?? + "https://api.openai.com/v1"; + + return new(new Uri(endpoint), new AzureKeyCredential(apiKey)); + } + + return null; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj new file mode 100644 index 00000000000..d992413109b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj @@ -0,0 +1,22 @@ + + + Microsoft.Extensions.AI + Unit tests for Microsoft.Extensions.AI.AzureAIInference + + + + true + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/BinaryEmbedding.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/BinaryEmbedding.cs new file mode 100644 index 00000000000..f538d1476b0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/BinaryEmbedding.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.AI; + +internal sealed class BinaryEmbedding : Embedding +{ + public BinaryEmbedding(ReadOnlyMemory bits) + { + Bits = bits; + } + + public ReadOnlyMemory Bits { get; } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/CallCountingChatClient.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/CallCountingChatClient.cs new file mode 100644 index 00000000000..c2aaa0d086d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/CallCountingChatClient.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable SA1402 // File may only contain a single type + +namespace Microsoft.Extensions.AI; + +internal sealed class CallCountingChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) +{ + private int _callCount; + + public int CallCount => _callCount; + + public override Task CompleteAsync( + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _callCount); + return base.CompleteAsync(chatMessages, options, cancellationToken); + } + + public override IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _callCount); + return base.CompleteStreamingAsync(chatMessages, options, cancellationToken); + } +} + +internal static class CallCountingChatClientBuilderExtensions +{ + public static ChatClientBuilder UseCallCounting(this ChatClientBuilder builder) => + builder.Use(innerClient => new CallCountingChatClient(innerClient)); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/CallCountingEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/CallCountingEmbeddingGenerator.cs new file mode 100644 index 00000000000..2930f94b6db --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/CallCountingEmbeddingGenerator.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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable SA1402 // File may only contain a single type + +namespace Microsoft.Extensions.AI; + +internal sealed class CallCountingEmbeddingGenerator(IEmbeddingGenerator> innerGenerator) + : DelegatingEmbeddingGenerator>(innerGenerator) +{ + private int _callCount; + + public int CallCount => _callCount; + + public override Task>> GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _callCount); + return base.GenerateAsync(values, options, cancellationToken); + } +} + +internal static class CallCountingEmbeddingGeneratorBuilderExtensions +{ + public static EmbeddingGeneratorBuilder> UseCallCounting( + this EmbeddingGeneratorBuilder> builder) => + builder.Use(innerGenerator => new CallCountingEmbeddingGenerator(innerGenerator)); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs new file mode 100644 index 00000000000..50257544430 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -0,0 +1,650 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.TestUtilities; +using OpenTelemetry.Trace; +using Xunit; + +#pragma warning disable CA2000 // Dispose objects before losing scope +#pragma warning disable CA2214 // Do not call overridable methods in constructors + +namespace Microsoft.Extensions.AI; + +public abstract class ChatClientIntegrationTests : IDisposable +{ + private readonly IChatClient? _chatClient; + + protected ChatClientIntegrationTests() + { + _chatClient = CreateChatClient(); + } + + public void Dispose() + { + _chatClient?.Dispose(); + GC.SuppressFinalize(this); + } + + protected abstract IChatClient? CreateChatClient(); + + [ConditionalFact] + public virtual async Task CompleteAsync_SingleRequestMessage() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync("What's the biggest animal?"); + + Assert.Contains("whale", response.Message.Text, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public virtual async Task CompleteAsync_MultipleRequestMessages() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync( + [ + new(ChatRole.User, "Pick a city, any city"), + new(ChatRole.Assistant, "Seattle"), + new(ChatRole.User, "And another one"), + new(ChatRole.Assistant, "Jakarta"), + new(ChatRole.User, "What continent are they each in?"), + ]); + + Assert.Single(response.Choices); + Assert.Contains("America", response.Message.Text); + Assert.Contains("Asia", response.Message.Text); + } + + [ConditionalFact] + public virtual async Task CompleteStreamingAsync_SingleStreamingResponseChoice() + { + SkipIfNotEnabled(); + + IList chatHistory = + [ + new(ChatRole.User, "Quote, word for word, Neil Armstrong's famous words.") + ]; + + StringBuilder sb = new(); + await foreach (var chunk in _chatClient.CompleteStreamingAsync(chatHistory)) + { + sb.Append(chunk.Text); + } + + string responseText = sb.ToString(); + Assert.Contains("one small step", responseText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("one giant leap", responseText, StringComparison.OrdinalIgnoreCase); + + // The input list is left unaugmented. + Assert.Single(chatHistory); + } + + [ConditionalFact] + public virtual async Task CompleteAsync_UsageDataAvailable() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync("Explain in 10 words how AI works"); + + Assert.Single(response.Choices); + Assert.True(response.Usage?.InputTokenCount > 1); + Assert.True(response.Usage?.OutputTokenCount > 1); + Assert.Equal(response.Usage?.InputTokenCount + response.Usage?.OutputTokenCount, response.Usage?.TotalTokenCount); + } + + [ConditionalFact] + public virtual async Task CompleteStreamingAsync_UsageDataAvailable() + { + SkipIfNotEnabled(); + + var response = _chatClient.CompleteStreamingAsync("Explain in 10 words how AI works"); + + List chunks = []; + await foreach (var chunk in response) + { + chunks.Add(chunk); + } + + Assert.True(chunks.Count > 1); + + UsageContent usage = chunks.SelectMany(c => c.Contents).OfType().Single(); + Assert.True(usage.Details.InputTokenCount > 1); + Assert.True(usage.Details.OutputTokenCount > 1); + Assert.Equal(usage.Details.InputTokenCount + usage.Details.OutputTokenCount, usage.Details.TotalTokenCount); + } + + [ConditionalFact] + public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_Parameterless() + { + SkipIfNotEnabled(); + + using var chatClient = new FunctionInvokingChatClient(_chatClient); + + int secretNumber = 42; + + var response = await chatClient.CompleteAsync("What is the current secret number?", new() + { + Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")] + }); + + Assert.Single(response.Choices); + Assert.Contains(secretNumber.ToString(), response.Message.Text); + } + + [ConditionalFact] + public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_WithParameters_NonStreaming() + { + SkipIfNotEnabled(); + + using var chatClient = new FunctionInvokingChatClient(_chatClient); + + var response = await chatClient.CompleteAsync("What is the result of SecretComputation on 42 and 84?", new() + { + Tools = [AIFunctionFactory.Create((int a, int b) => a * b, "SecretComputation")] + }); + + Assert.Single(response.Choices); + Assert.Contains("3528", response.Message.Text); + } + + [ConditionalFact] + public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_WithParameters_Streaming() + { + SkipIfNotEnabled(); + + using var chatClient = new FunctionInvokingChatClient(_chatClient); + + var response = chatClient.CompleteStreamingAsync("What is the result of SecretComputation on 42 and 84?", new() + { + Tools = [AIFunctionFactory.Create((int a, int b) => a * b, "SecretComputation")] + }); + + StringBuilder sb = new(); + await foreach (var chunk in response) + { + sb.Append(chunk.Text); + } + + Assert.Contains("3528", sb.ToString()); + } + + protected virtual bool SupportsParallelFunctionCalling => true; + + [ConditionalFact] + public virtual async Task FunctionInvocation_SupportsMultipleParallelRequests() + { + SkipIfNotEnabled(); + if (!SupportsParallelFunctionCalling) + { + throw new SkipTestException("Parallel function calling is not supported by this chat client"); + } + + 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.CompleteAsync("How much older is Elsa than Anna? Return the age difference as a single number.", new() + { + Tools = [AIFunctionFactory.Create((string personName) => + { + return personName switch + { + "Elsa" => 21, + "Anna" => 18, + _ => 30, + }; + }, "GetPersonAge")] + }); + + Assert.True( + Regex.IsMatch(response.Message.Text ?? "", @"\b(3|three)\b", RegexOptions.IgnoreCase), + $"Doesn't contain three: {response.Message.Text}"); + } + + [ConditionalFact] + public virtual async Task FunctionInvocation_RequireAny() + { + SkipIfNotEnabled(); + + int callCount = 0; + var tool = AIFunctionFactory.Create(() => + { + callCount++; + return 123; + }, "GetSecretNumber"); + + using var chatClient = new FunctionInvokingChatClient(_chatClient); + + var response = await chatClient.CompleteAsync("Are birds real?", new() + { + Tools = [tool], + ToolMode = ChatToolMode.RequireAny, + }); + + Assert.Single(response.Choices); + Assert.True(callCount >= 1); + } + + [ConditionalFact] + public virtual async Task FunctionInvocation_RequireSpecific() + { + SkipIfNotEnabled(); + + bool shieldsUp = false; + var getSecretNumberTool = AIFunctionFactory.Create(() => 123, "GetSecretNumber"); + var shieldsUpTool = AIFunctionFactory.Create(() => shieldsUp = true, "ShieldsUp"); + + 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.CompleteAsync("What's the current secret number?", new() + { + Tools = [getSecretNumberTool, shieldsUpTool], + ToolMode = ChatToolMode.RequireSpecific(shieldsUpTool.Metadata.Name), + }); + + Assert.True(shieldsUp); + } + + [ConditionalFact] + 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.CompleteAsync([message]); + Assert.Single(firstResponse.Choices); + + var secondResponse = await _chatClient.CompleteAsync([message]); + Assert.NotEqual(firstResponse.Message.Text, secondResponse.Message.Text); + } + + [ConditionalFact] + public virtual async Task Caching_SamePromptResultsInCacheHit_NonStreaming() + { + SkipIfNotEnabled(); + + using var chatClient = new DistributedCachingChatClient( + _chatClient, + new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))); + + var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); + var firstResponse = await chatClient.CompleteAsync([message]); + Assert.Single(firstResponse.Choices); + + // No matter what it said before, we should see identical output due to caching + for (int i = 0; i < 3; i++) + { + var secondResponse = await chatClient.CompleteAsync([message]); + Assert.Equal(firstResponse.Message.Text, secondResponse.Message.Text); + } + + // ... but if the conversation differs, we should see different output + message.Text += "!"; + var thirdResponse = await chatClient.CompleteAsync([message]); + Assert.NotEqual(firstResponse.Message.Text, thirdResponse.Message.Text); + } + + [ConditionalFact] + public virtual async Task Caching_SamePromptResultsInCacheHit_Streaming() + { + SkipIfNotEnabled(); + + using var chatClient = new DistributedCachingChatClient( + _chatClient, + new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))); + + var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); + StringBuilder orig = new(); + await foreach (var update in chatClient.CompleteStreamingAsync([message])) + { + orig.Append(update.Text); + } + + // No matter what it said before, we should see identical output due to caching + for (int i = 0; i < 3; i++) + { + StringBuilder second = new(); + await foreach (var update in chatClient.CompleteStreamingAsync([message])) + { + second.Append(update.Text); + } + + Assert.Equal(orig.ToString(), second.ToString()); + } + + // ... but if the conversation differs, we should see different output + message.Text += "!"; + StringBuilder third = new(); + await foreach (var update in chatClient.CompleteStreamingAsync([message])) + { + third.Append(update.Text); + } + + Assert.NotEqual(orig.ToString(), third.ToString()); + } + + [ConditionalFact] + public virtual async Task Caching_BeforeFunctionInvocation_AvoidsExtraCalls() + { + SkipIfNotEnabled(); + + int functionCallCount = 0; + var getTemperature = AIFunctionFactory.Create([Description("Gets the current temperature")] () => + { + functionCallCount++; + return $"{100 + functionCallCount} degrees celsius"; + }, "GetTemperature"); + + // First call executes the function and calls the LLM + using var chatClient = new ChatClientBuilder() + .UseChatOptions(_ => new() { Tools = [getTemperature] }) + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .UseFunctionInvocation() + .UseCallCounting() + .Use(CreateChatClient()!); + + var llmCallCount = chatClient.GetService(); + var message = new ChatMessage(ChatRole.User, "What is the temperature?"); + var response = await chatClient.CompleteAsync([message]); + Assert.Contains("101", response.Message.Text); + + // First LLM call tells us to call the function, second deals with the result + Assert.Equal(2, llmCallCount!.CallCount); + + // Second call doesn't execute the function or call the LLM, but rather just returns the cached result + var secondResponse = await chatClient.CompleteAsync([message]); + Assert.Equal(response.Message.Text, secondResponse.Message.Text); + Assert.Equal(1, functionCallCount); + Assert.Equal(2, llmCallCount!.CallCount); + } + + [ConditionalFact] + public virtual async Task Caching_AfterFunctionInvocation_FunctionOutputUnchangedAsync() + { + SkipIfNotEnabled(); + + // This means that if the function call produces the same result, we can avoid calling the LLM + // whereas if the function call produces a different result, we do call the LLM + + var functionCallCount = 0; + var getTemperature = AIFunctionFactory.Create([Description("Gets the current temperature")] () => + { + functionCallCount++; + return "58 degrees celsius"; + }, "GetTemperature"); + + // First call executes the function and calls the LLM + using var chatClient = new ChatClientBuilder() + .UseChatOptions(_ => new() { Tools = [getTemperature] }) + .UseFunctionInvocation() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .UseCallCounting() + .Use(CreateChatClient()!); + + var llmCallCount = chatClient.GetService(); + var message = new ChatMessage(ChatRole.User, "What is the temperature?"); + var response = await chatClient.CompleteAsync([message]); + Assert.Contains("58", response.Message.Text); + + // First LLM call tells us to call the function, second deals with the result + Assert.Equal(1, functionCallCount); + Assert.Equal(2, llmCallCount!.CallCount); + + // Second time, the calls to the LLM don't happen, but the function is called again + var secondResponse = await chatClient.CompleteAsync([message]); + Assert.Equal(response.Message.Text, secondResponse.Message.Text); + Assert.Equal(2, functionCallCount); + Assert.Equal(2, llmCallCount!.CallCount); + } + + [ConditionalFact] + public virtual async Task Caching_AfterFunctionInvocation_FunctionOutputChangedAsync() + { + SkipIfNotEnabled(); + + // This means that if the function call produces the same result, we can avoid calling the LLM + // whereas if the function call produces a different result, we do call the LLM + + var functionCallCount = 0; + var getTemperature = AIFunctionFactory.Create([Description("Gets the current temperature")] () => + { + functionCallCount++; + return $"{80 + functionCallCount} degrees celsius"; + }, "GetTemperature"); + + // First call executes the function and calls the LLM + using var chatClient = new ChatClientBuilder() + .UseChatOptions(_ => new() { Tools = [getTemperature] }) + .UseFunctionInvocation() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .UseCallCounting() + .Use(CreateChatClient()!); + + var llmCallCount = chatClient.GetService(); + var message = new ChatMessage(ChatRole.User, "What is the temperature?"); + var response = await chatClient.CompleteAsync([message]); + Assert.Contains("81", response.Message.Text); + + // First LLM call tells us to call the function, second deals with the result + Assert.Equal(1, functionCallCount); + Assert.Equal(2, llmCallCount!.CallCount); + + // Second time, the first call to the LLM don't happen, but the function is called again, + // and since its output now differs, we no longer hit the cache so the second LLM call does happen + var secondResponse = await chatClient.CompleteAsync([message]); + Assert.Contains("82", secondResponse.Message.Text); + Assert.Equal(2, functionCallCount); + Assert.Equal(3, llmCallCount!.CallCount); + } + + [ConditionalFact] + public virtual async Task Logging_LogsCalls_NonStreaming() + { + SkipIfNotEnabled(); + + CapturingLogger logger = new(); + + using var chatClient = + new LoggingChatClient(CreateChatClient()!, logger); + + await chatClient.CompleteAsync([new(ChatRole.User, "What's the biggest animal?")]); + + Assert.Collection(logger.Entries, + entry => Assert.Contains("What\\u0027s the biggest animal?", entry.Message), + entry => Assert.Contains("whale", entry.Message)); + } + + [ConditionalFact] + public virtual async Task Logging_LogsCalls_Streaming() + { + SkipIfNotEnabled(); + + CapturingLogger logger = new(); + + using var chatClient = + new LoggingChatClient(CreateChatClient()!, logger); + + await foreach (var update in chatClient.CompleteStreamingAsync("What's the biggest animal?")) + { + // Do nothing with the updates + } + + Assert.Contains(logger.Entries, e => e.Message.Contains("What\\u0027s the biggest animal?")); + Assert.Contains(logger.Entries, e => e.Message.Contains("whale")); + } + + [ConditionalFact] + public virtual async Task Logging_LogsFunctionCalls_NonStreaming() + { + SkipIfNotEnabled(); + + CapturingLogger logger = new(); + + using var chatClient = + new FunctionInvokingChatClient( + new LoggingChatClient(CreateChatClient()!, logger)); + + int secretNumber = 42; + await chatClient.CompleteAsync( + "What is the current secret number?", + new ChatOptions { Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")] }); + + Assert.Collection(logger.Entries, + entry => Assert.Contains("What is the current secret number?", entry.Message), + entry => Assert.Contains("\"name\":\"GetSecretNumber\"", entry.Message), + entry => Assert.Contains($"\"result\":{secretNumber}", entry.Message), + entry => Assert.Contains(secretNumber.ToString(), entry.Message)); + } + + [ConditionalFact] + public virtual async Task Logging_LogsFunctionCalls_Streaming() + { + SkipIfNotEnabled(); + + CapturingLogger logger = new(); + + using var chatClient = + new FunctionInvokingChatClient( + new LoggingChatClient(CreateChatClient()!, logger)); + + int secretNumber = 42; + await foreach (var update in chatClient.CompleteStreamingAsync( + "What is the current secret number?", + new ChatOptions { Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")] })) + { + // Do nothing with the updates + } + + Assert.Contains(logger.Entries, e => e.Message.Contains("What is the current secret number?")); + Assert.Contains(logger.Entries, e => e.Message.Contains("\"name\":\"GetSecretNumber\"")); + Assert.Contains(logger.Entries, e => e.Message.Contains($"\"result\":{secretNumber}")); + } + + [ConditionalFact] + public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() + { + SkipIfNotEnabled(); + + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var chatClient = new ChatClientBuilder() + .UseOpenTelemetry(sourceName, instance => { instance.EnableSensitiveData = true; }) + .Use(CreateChatClient()!); + + var response = await chatClient.CompleteAsync([new(ChatRole.User, "What's the biggest animal?")]); + + var activity = Assert.Single(activities); + Assert.StartsWith("chat.completions", activity.DisplayName); + Assert.StartsWith("http", (string)activity.GetTagItem("server.address")!); + Assert.Equal(chatClient.Metadata.ProviderUri?.Port, (int)activity.GetTagItem("server.port")!); + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.response.input_tokens")!); + Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.response.output_tokens")!); + + Assert.Collection(activity.Events, + evt => + { + Assert.Equal("gen_ai.content.prompt", evt.Name); + Assert.Equal("""[{"role": "user", "content": "What\u0027s the biggest animal?"}]""", evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.prompt").Value); + }, + evt => + { + Assert.Equal("gen_ai.content.completion", evt.Name); + Assert.Contains("whale", (string)evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.completion").Value!); + }); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + } + + [ConditionalFact] + public virtual async Task CompleteAsync_StructuredOutput() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync(""" + Who is described in the following sentence? + Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. + """); + + Assert.Equal("Jimbo Smith", response.Result.FullName); + Assert.Equal(35, response.Result.AgeInYears); + Assert.Contains("Cardiff", response.Result.HomeTown); + Assert.Equal(JobType.Programmer, response.Result.Job); + } + + [ConditionalFact] + public virtual async Task CompleteAsync_StructuredOutput_WithFunctions() + { + SkipIfNotEnabled(); + + var expectedPerson = new Person + { + FullName = "Jimbo Smith", + AgeInYears = 35, + HomeTown = "Cardiff", + Job = JobType.Programmer, + }; + + using var chatClient = new FunctionInvokingChatClient(_chatClient); + var response = await chatClient.CompleteAsync( + "Who is person with ID 123?", new ChatOptions + { + Tools = [AIFunctionFactory.Create((int personId) => + { + Assert.Equal(123, personId); + return expectedPerson; + }, "GetPersonById")] + }); + + Assert.NotSame(expectedPerson, response.Result); + Assert.Equal(expectedPerson.FullName, response.Result.FullName); + Assert.Equal(expectedPerson.AgeInYears, response.Result.AgeInYears); + Assert.Equal(expectedPerson.HomeTown, response.Result.HomeTown); + Assert.Equal(expectedPerson.Job, response.Result.Job); + } + + private class Person + { +#pragma warning disable S1144, S3459 // Unassigned members should be removed + public string? FullName { get; set; } + public int AgeInYears { get; set; } + public string? HomeTown { get; set; } + public JobType Job { get; set; } +#pragma warning restore S1144, S3459 // Unused private types or members should be removed + } + + private enum JobType + { + Surgeon, + PopStar, + Programmer, + Unknown, + } + + [MemberNotNull(nameof(_chatClient))] + protected void SkipIfNotEnabled() + { + if (_chatClient is null) + { + throw new SkipTestException("Client is not enabled."); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs new file mode 100644 index 00000000000..252427836e8 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs @@ -0,0 +1,215 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +#if NET +using System.Numerics.Tensors; +#endif +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.TestUtilities; +using OpenTelemetry.Trace; +using Xunit; + +#pragma warning disable CA2214 // Do not call overridable methods in constructors +#pragma warning disable S3967 // Multidimensional arrays should not be used + +namespace Microsoft.Extensions.AI; + +public abstract class EmbeddingGeneratorIntegrationTests : IDisposable +{ + private readonly IEmbeddingGenerator>? _embeddingGenerator; + + protected EmbeddingGeneratorIntegrationTests() + { + _embeddingGenerator = CreateEmbeddingGenerator(); + } + + public void Dispose() + { + _embeddingGenerator?.Dispose(); + GC.SuppressFinalize(this); + } + + protected abstract IEmbeddingGenerator>? CreateEmbeddingGenerator(); + + [ConditionalFact] + public virtual async Task GenerateEmbedding_CreatesEmbeddingSuccessfully() + { + SkipIfNotEnabled(); + + var embeddings = await _embeddingGenerator.GenerateAsync("Using AI with .NET"); + + Assert.NotNull(embeddings.Usage); + Assert.NotNull(embeddings.Usage.InputTokenCount); + Assert.NotNull(embeddings.Usage.TotalTokenCount); + Assert.Single(embeddings); + Assert.Equal(_embeddingGenerator.Metadata.ModelId, embeddings[0].ModelId); + Assert.NotEmpty(embeddings[0].Vector.ToArray()); + } + + [ConditionalFact] + public virtual async Task GenerateEmbeddings_CreatesEmbeddingsSuccessfully() + { + SkipIfNotEnabled(); + + var embeddings = await _embeddingGenerator.GenerateAsync([ + "Red", + "White", + "Blue", + ]); + + Assert.Equal(3, embeddings.Count); + Assert.NotNull(embeddings.Usage); + Assert.NotNull(embeddings.Usage.InputTokenCount); + Assert.NotNull(embeddings.Usage.TotalTokenCount); + Assert.All(embeddings, embedding => + { + Assert.Equal(_embeddingGenerator.Metadata.ModelId, embedding.ModelId); + Assert.NotEmpty(embedding.Vector.ToArray()); + }); + } + + [ConditionalFact] + public virtual async Task Caching_SameOutputsForSameInput() + { + SkipIfNotEnabled(); + + using var generator = new EmbeddingGeneratorBuilder>() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .UseCallCounting() + .Use(CreateEmbeddingGenerator()!); + + string input = "Red, White, and Blue"; + var embedding1 = await generator.GenerateAsync(input); + var embedding2 = await generator.GenerateAsync(input); + var embedding3 = await generator.GenerateAsync(input + "... and Green"); + var embedding4 = await generator.GenerateAsync(input); + + var callCounter = generator.GetService(); + Assert.NotNull(callCounter); + + Assert.Equal(2, callCounter.CallCount); + } + + [ConditionalFact] + public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() + { + SkipIfNotEnabled(); + + string sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var embeddingGenerator = new EmbeddingGeneratorBuilder>() + .UseOpenTelemetry(sourceName) + .Use(CreateEmbeddingGenerator()!); + + _ = await embeddingGenerator.GenerateAsync("Hello, world!"); + + Assert.Single(activities); + var activity = activities.Single(); + Assert.StartsWith("embedding", activity.DisplayName); + Assert.StartsWith("http", (string)activity.GetTagItem("server.address")!); + Assert.Equal(embeddingGenerator.Metadata.ProviderUri?.Port, (int)activity.GetTagItem("server.port")!); + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.response.input_tokens")!); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + } + +#if NET + [ConditionalFact] + public async Task Quantization_Binary_EmbeddingsCompareSuccessfully() + { + SkipIfNotEnabled(); + + using IEmbeddingGenerator generator = + new QuantizationEmbeddingGenerator( + CreateEmbeddingGenerator()!); + + var embeddings = await generator.GenerateAsync(["dog", "cat", "fork", "spoon"]); + Assert.Equal(4, embeddings.Count); + + long[,] distances = new long[embeddings.Count, embeddings.Count]; + for (int i = 0; i < embeddings.Count; i++) + { + for (int j = 0; j < embeddings.Count; j++) + { + distances[i, j] = TensorPrimitives.HammingBitDistance(embeddings[i].Bits.Span, embeddings[j].Bits.Span); + } + } + + for (int i = 0; i < embeddings.Count; i++) + { + Assert.Equal(0, distances[i, i]); + } + + Assert.True(distances[0, 1] < distances[0, 2]); + Assert.True(distances[0, 1] < distances[0, 3]); + Assert.True(distances[0, 1] < distances[1, 2]); + Assert.True(distances[0, 1] < distances[1, 3]); + + Assert.True(distances[2, 3] < distances[0, 2]); + Assert.True(distances[2, 3] < distances[0, 3]); + Assert.True(distances[2, 3] < distances[1, 2]); + Assert.True(distances[2, 3] < distances[1, 3]); + } + + [ConditionalFact] + public async Task Quantization_Half_EmbeddingsCompareSuccessfully() + { + SkipIfNotEnabled(); + + using IEmbeddingGenerator> generator = + new QuantizationEmbeddingGenerator( + CreateEmbeddingGenerator()!); + + var embeddings = await generator.GenerateAsync(["dog", "cat", "fork", "spoon"]); + Assert.Equal(4, embeddings.Count); + + var distances = new Half[embeddings.Count, embeddings.Count]; + for (int i = 0; i < embeddings.Count; i++) + { + for (int j = 0; j < embeddings.Count; j++) + { + distances[i, j] = TensorPrimitives.CosineSimilarity(embeddings[i].Vector.Span, embeddings[j].Vector.Span); + } + } + + for (int i = 0; i < embeddings.Count; i++) + { + Assert.Equal(1.0, (double)distances[i, i], 0.001); + } + + Assert.True(distances[0, 1] > distances[0, 2]); + Assert.True(distances[0, 1] > distances[0, 3]); + Assert.True(distances[0, 1] > distances[1, 2]); + Assert.True(distances[0, 1] > distances[1, 3]); + + Assert.True(distances[2, 3] > distances[0, 2]); + Assert.True(distances[2, 3] > distances[0, 3]); + Assert.True(distances[2, 3] > distances[1, 2]); + Assert.True(distances[2, 3] > distances[1, 3]); + } +#endif + + [MemberNotNull(nameof(_embeddingGenerator))] + protected void SkipIfNotEnabled() + { + if (_embeddingGenerator is null) + { + throw new SkipTestException("Generator is not enabled."); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj new file mode 100644 index 00000000000..e38ccd3268b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj @@ -0,0 +1,37 @@ + + + Microsoft.Extensions.AI + Opt-in integration tests for Microsoft.Extensions.AI. + + + + $(NoWarn);CA1063;CA1861;SA1130;VSTHRD003 + true + + + + true + true + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/PromptBasedFunctionCallingChatClient.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/PromptBasedFunctionCallingChatClient.cs new file mode 100644 index 00000000000..150c984ff86 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/PromptBasedFunctionCallingChatClient.cs @@ -0,0 +1,228 @@ +// 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.Collections.ObjectModel; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable S1144 // Unused private types or members should be removed +#pragma warning disable S3459 // Unassigned members should be removed + +namespace Microsoft.Extensions.AI; + +// This isn't a feature we're planning to ship, but demonstrates how custom clients can +// layer in non-trivial functionality. In this case we're able to upgrade non-function-calling models +// to behaving as if they do support function calling. +// +// In practice: +// - For llama3:8b or mistral:7b, this works fairly reliably, at least when it only needs to +// make a single function call with a constrained set of args. +// - For smaller models like phi3:mini, it works only on a more occasional basis (e.g., if there's +// only one function defined, and it takes no arguments, but is very hit-and-miss beyond that). + +internal sealed class PromptBasedFunctionCallingChatClient(IChatClient innerClient) + : DelegatingChatClient(innerClient) +{ + private const string MessageIntro = "You are an AI model with function calling capabilities. Call one or more functions if they are relevant to the user's query."; + + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public override async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + // Our goal is to convert tools into a prompt describing them, then to detect tool calls in the + // response and convert those into FunctionCallContent. + if (options?.Tools is { Count: > 0 }) + { + AddOrUpdateToolPrompt(chatMessages, options.Tools); + options = options.Clone(); + options.Tools = null; + + options.StopSequences ??= []; + if (!options.StopSequences.Contains("")) + { + options.StopSequences.Add(""); + } + + // Since the point of this client is to avoid relying on the underlying model having + // native tool call support, we have to replace any "tool" or "toolcall" messages with + // "user" or "assistant" ones. + foreach (var message in chatMessages) + { + for (var itemIndex = 0; itemIndex < message.Contents.Count; itemIndex++) + { + if (message.Contents[itemIndex] is FunctionResultContent frc) + { + var toolCallResultJson = JsonSerializer.Serialize(new ToolCallResult { Id = frc.CallId, Result = frc.Result }, _jsonOptions); + message.Role = ChatRole.User; + message.Contents[itemIndex] = new TextContent( + $"{toolCallResultJson}"); + } + else if (message.Contents[itemIndex] is FunctionCallContent fcc) + { + var toolCallJson = JsonSerializer.Serialize(new { fcc.CallId, fcc.Name, fcc.Arguments }, _jsonOptions); + message.Role = ChatRole.Assistant; + message.Contents[itemIndex] = new TextContent( + $"{toolCallJson}"); + } + } + } + } + + var result = await base.CompleteAsync(chatMessages, options, cancellationToken); + + if (result.Choices.FirstOrDefault()?.Text is { } content && content.IndexOf("", StringComparison.Ordinal) is int startPos + && startPos >= 0) + { + var message = result.Choices.First(); + var contentItem = message.Contents.SingleOrDefault(); + content = content.Substring(startPos); + + foreach (var toolCallJson in content.Split([""], StringSplitOptions.None)) + { + var toolCall = toolCallJson.Trim(); + if (toolCall.Length == 0) + { + continue; + } + + var endPos = toolCall.IndexOf(" 0) + { + toolCall = toolCall.Substring(0, endPos); + try + { + var toolCallParsed = JsonSerializer.Deserialize(toolCall, _jsonOptions); + if (!string.IsNullOrEmpty(toolCallParsed?.Name)) + { + if (toolCallParsed!.Arguments is not null) + { + ParseArguments(toolCallParsed.Arguments); + } + + var id = Guid.NewGuid().ToString().Substring(0, 6); + message.Contents.Add(new FunctionCallContent(id, toolCallParsed.Name!, toolCallParsed.Arguments is { } args ? new ReadOnlyDictionary(args) : null)); + + if (contentItem is not null) + { + message.Contents.Remove(contentItem); + } + } + } + catch (JsonException) + { + // Ignore invalid tool calls + } + } + } + } + + return result; + } + + private static void ParseArguments(IDictionary arguments) + { + // This is a simple implementation. A more robust answer is to use other schema information given by + // the AIFunction here, as for example is done in OpenAIChatClient. + foreach (var kvp in arguments.ToArray()) + { + if (kvp.Value is JsonElement jsonElement) + { + arguments[kvp.Key] = jsonElement.ValueKind switch + { + JsonValueKind.String => jsonElement.GetString(), + JsonValueKind.Number => jsonElement.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => jsonElement.ToString() + }; + } + } + } + + private static void AddOrUpdateToolPrompt(IList chatMessages, IList tools) + { + var existingToolPrompt = chatMessages.FirstOrDefault(c => c.Text?.StartsWith(MessageIntro, StringComparison.Ordinal) is true); + if (existingToolPrompt is null) + { + existingToolPrompt = new ChatMessage(ChatRole.System, (string?)null); + chatMessages.Insert(0, existingToolPrompt); + } + + var toolDescriptorsJson = JsonSerializer.Serialize(tools.OfType().Select(ToToolDescriptor), _jsonOptions); + existingToolPrompt.Text = $$""" + {{MessageIntro}} + + For each function call, return a JSON object with the function name and arguments within XML tags + as follows: + + {"name": "tool_name", "arguments": { argname1: argval1, argname2: argval2, ... } } + + Note that the contents of MUST be a valid JSON object, with no other text. + + Once you receive the result as a JSON object within XML tags, use it to + answer the user's question without repeating the same tool call. + + Here are the available tools: + {{toolDescriptorsJson}} + """; + } + + private static ToolDescriptor ToToolDescriptor(AIFunction tool) => new() + { + Name = tool.Metadata.Name, + Description = tool.Metadata.Description, + Arguments = tool.Metadata.Parameters.ToDictionary( + p => p.Name, + p => new ToolParameterDescriptor + { + Type = p.ParameterType?.Name, + Description = p.Description, + Enum = p.ParameterType?.IsEnum == true ? Enum.GetNames(p.ParameterType) : null, + Required = p.IsRequired, + }), + }; + + private sealed class ToolDescriptor + { + public string? Name { get; set; } + public string? Description { get; set; } + public IDictionary? Arguments { get; set; } + } + + private sealed class ToolParameterDescriptor + { + public string? Type { get; set; } + public string? Description { get; set; } + public bool? Required { get; set; } + public string[]? Enum { get; set; } + } + + private sealed class ToolCall + { + public string? Id { get; set; } + public string? Name { get; set; } + public IDictionary? Arguments { get; set; } + } + + private sealed class ToolCallResult + { + public string? Id { get; set; } + public object? Result { get; set; } + } +} + +public static class PromptBasedFunctionCallingChatClientExtensions +{ + public static ChatClientBuilder UsePromptBasedFunctionCalling(this ChatClientBuilder builder) + => builder.Use(innerClient => new PromptBasedFunctionCallingChatClient(innerClient)); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs new file mode 100644 index 00000000000..90032f16434 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs @@ -0,0 +1,94 @@ +// 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; +#if NET +using System.Numerics.Tensors; +#endif +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +internal sealed class QuantizationEmbeddingGenerator : + IEmbeddingGenerator +#if NET + , IEmbeddingGenerator> +#endif +{ + private readonly IEmbeddingGenerator> _floatService; + + public QuantizationEmbeddingGenerator(IEmbeddingGenerator> floatService) + { + _floatService = floatService; + } + + public EmbeddingGeneratorMetadata Metadata => _floatService.Metadata; + + void IDisposable.Dispose() => _floatService.Dispose(); + + public TService? GetService(object? key = null) + where TService : class => + key is null && this is TService ? (TService?)(object)this : + _floatService.GetService(key); + + async Task> IEmbeddingGenerator.GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options, CancellationToken cancellationToken) + { + var embeddings = await _floatService.GenerateAsync(values, options, cancellationToken).ConfigureAwait(false); + return new(from e in embeddings select QuantizeToBinary(e)) + { + Usage = embeddings.Usage, + AdditionalProperties = embeddings.AdditionalProperties, + }; + } + + private static BinaryEmbedding QuantizeToBinary(Embedding embedding) + { + ReadOnlySpan vector = embedding.Vector.Span; + + var result = new byte[(int)Math.Ceiling(vector.Length / 8.0)]; + for (int i = 0; i < vector.Length; i++) + { + if (vector[i] > 0) + { + result[i / 8] |= (byte)(1 << (i % 8)); + } + } + + return new(result) + { + CreatedAt = embedding.CreatedAt, + ModelId = embedding.ModelId, + AdditionalProperties = embedding.AdditionalProperties, + }; + } + +#if NET + async Task>> IEmbeddingGenerator>.GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options, CancellationToken cancellationToken) + { + var embeddings = await _floatService.GenerateAsync(values, options, cancellationToken).ConfigureAwait(false); + return new(from e in embeddings select QuantizeToHalf(e)) + { + Usage = embeddings.Usage, + AdditionalProperties = embeddings.AdditionalProperties, + }; + } + + private static Embedding QuantizeToHalf(Embedding embedding) + { + ReadOnlySpan vector = embedding.Vector.Span; + var result = new Half[vector.Length]; + TensorPrimitives.ConvertToHalf(vector, result); + return new(result) + { + CreatedAt = embedding.CreatedAt, + ModelId = embedding.ModelId, + AdditionalProperties = embedding.AdditionalProperties, + }; + } +#endif +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs new file mode 100644 index 00000000000..0c436f7ccb5 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs @@ -0,0 +1,201 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ML.Tokenizers; +using Microsoft.Shared.Diagnostics; +using Xunit; + +#pragma warning disable S103 // Lines should not be too long +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + +namespace Microsoft.Extensions.AI; + +/// Provides an example of a custom for reducing chat message lists. +public class ReducingChatClientTests +{ + private static readonly Tokenizer _gpt4oTokenizer = TiktokenTokenizer.CreateForModel("gpt-4o"); + + [Fact] + public async Task Reduction_LimitsMessagesBasedOnTokenLimit() + { + using var innerClient = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(2, messages.Count); + Assert.Collection(messages, + m => Assert.StartsWith("Golden retrievers are quite active", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Are they good with kids?", m.Text, StringComparison.Ordinal)); + return Task.FromResult(new ChatCompletion([])); + } + }; + + using var client = new ChatClientBuilder() + .UseChatReducer(new TokenCountingChatReducer(_gpt4oTokenizer, 40)) + .Use(innerClient); + + 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?"), + ]; + + await client.CompleteAsync(messages); + + Assert.Equal(5, messages.Count); + } +} + +/// Provides an example of a chat client for reducing the size of a message list. +public sealed class ReducingChatClient : DelegatingChatClient +{ + private readonly IChatReducer _reducer; + private readonly bool _inPlace; + + /// Initializes a new instance of the class. + /// The inner client. + /// The reducer to be used by this instance. + /// + /// true if the should perform any modifications directly on the supplied list of messages; + /// false if it should instead create a new list when reduction is necessary. + /// + public ReducingChatClient(IChatClient innerClient, IChatReducer reducer, bool inPlace = false) + : base(innerClient) + { + _reducer = Throw.IfNull(reducer); + _inPlace = inPlace; + } + + /// + public override async Task CompleteAsync( + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + chatMessages = await GetChatMessagesToPropagate(chatMessages, cancellationToken).ConfigureAwait(false); + + return await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + chatMessages = await GetChatMessagesToPropagate(chatMessages, cancellationToken).ConfigureAwait(false); + + await foreach (var update in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + /// Runs the reducer and gets the chat message list to forward to the inner client. + private async Task> GetChatMessagesToPropagate(IList chatMessages, CancellationToken cancellationToken) => + await _reducer.ReduceAsync(chatMessages, _inPlace, cancellationToken).ConfigureAwait(false) ?? + chatMessages; +} + +/// 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. + /// true if the reducer should modify the provided list; false if a new list should be returned. + /// The to monitor for cancellation requests. The default is . + /// The new list of messages, or null if no reduction need be performed or was true. + Task?> ReduceAsync(IList chatMessages, bool inPlace, CancellationToken cancellationToken); +} + +/// Provides extensions for configuring instances. +public static class ReducingChatClientExtensions +{ + public static ChatClientBuilder UseChatReducer(this ChatClientBuilder builder, IChatReducer reducer, bool inPlace = false) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(reducer); + + return builder.Use(innerClient => new ReducingChatClient(innerClient, reducer, inPlace)); + } +} + +/// An that culls the oldest messages once a certain token threshold is reached. +public sealed class TokenCountingChatReducer : IChatReducer +{ + private readonly Tokenizer _tokenizer; + private readonly int _tokenLimit; + + public TokenCountingChatReducer(Tokenizer tokenizer, int tokenLimit) + { + _tokenizer = Throw.IfNull(tokenizer); + _tokenLimit = Throw.IfLessThan(tokenLimit, 1); + } + + public async Task?> ReduceAsync(IList chatMessages, bool inPlace, CancellationToken cancellationToken) + { + _ = Throw.IfNull(chatMessages); + + if (chatMessages.Count > 1) + { + int totalCount = CountTokens(chatMessages[chatMessages.Count - 1]); + + if (inPlace) + { + for (int i = chatMessages.Count - 2; i >= 0; i--) + { + totalCount += CountTokens(chatMessages[i]); + if (totalCount > _tokenLimit) + { + if (chatMessages is List list) + { + list.RemoveRange(0, i + 1); + } + else + { + for (int j = i; j >= 0; j--) + { + chatMessages.RemoveAt(j); + } + } + + break; + } + } + } + else + { + for (int i = chatMessages.Count - 2; i >= 0; i--) + { + totalCount += CountTokens(chatMessages[i]); + if (totalCount > _tokenLimit) + { + return chatMessages.Skip(i + 1).ToList(); + } + } + } + } + + return null; + } + + private int CountTokens(ChatMessage message) + { + int sum = 0; + foreach (AIContent content in message.Contents) + { + if ((content as TextContent)?.Text is string text) + { + sum += _tokenizer.CountTokens(text); + } + } + + return sum; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs new file mode 100644 index 00000000000..14ba68feb7a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +/// +/// An that checks the request body against an expected one +/// and sends back an expected response. +/// +public sealed class VerbatimHttpHandler(string expectedInput, string sentOutput) : HttpMessageHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Assert.NotNull(request.Content); + + string? input = await request.Content +#if NET + .ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + .ReadAsStringAsync().ConfigureAwait(false); +#endif + + Assert.NotNull(input); + Assert.Equal(RemoveWhiteSpace(expectedInput), RemoveWhiteSpace(input)); + + return new() { Content = new StringContent(sentOutput) }; + } + + public static string? RemoveWhiteSpace(string? text) => + text is null ? null : + Regex.Replace(text, @"\s*", string.Empty); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs new file mode 100644 index 00000000000..d25d750ce37 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.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; + +namespace Microsoft.Extensions.AI; + +#pragma warning disable S125 // Sections of code should not be commented out + +/// Shared utility methods for integration tests. +internal static class IntegrationTestHelpers +{ + /// Gets a to use for testing, or null if the associated tests should be disabled. + public static Uri? GetOllamaUri() + { + // return new Uri("http://localhost:11434"); + return null; + } +} 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 new file mode 100644 index 00000000000..5db789e3b6b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj @@ -0,0 +1,22 @@ + + + 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 new file mode 100644 index 00000000000..891378c0e86 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs @@ -0,0 +1,101 @@ +// 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_AutomaticallyInvokeFunction_WithParameters_Streaming() => + throw new SkipTestException("Ollama does not currently support function invocation with streaming."); + + public override Task Logging_LogsFunctionCalls_Streaming() => + throw new SkipTestException("Ollama does not currently support function invocation with streaming."); + + 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."); + + [ConditionalFact] + public async Task PromptBasedFunctionCalling_NoArgs() + { + SkipIfNotEnabled(); + + using var chatClient = new ChatClientBuilder() + .UseFunctionInvocation() + .UsePromptBasedFunctionCalling() + .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) + .Use(CreateChatClient()!); + + var secretNumber = 42; + var response = await chatClient.CompleteAsync("What is the current secret number? Answer with digits only.", new ChatOptions + { + ModelId = "llama3:8b", + Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")], + Temperature = 0, + AdditionalProperties = new() { ["seed"] = 0L }, + }); + + Assert.Single(response.Choices); + Assert.Contains(secretNumber.ToString(), response.Message.Text); + } + + [ConditionalFact] + public async Task PromptBasedFunctionCalling_WithArgs() + { + SkipIfNotEnabled(); + + using var chatClient = new ChatClientBuilder() + .UseFunctionInvocation() + .UsePromptBasedFunctionCalling() + .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) + .Use(CreateChatClient()!); + + 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.CompleteAsync("What's the stock price for Microsoft in British pounds?", new ChatOptions + { + Tools = [stockPriceTool, irrelevantTool], + Temperature = 0, + AdditionalProperties = new() { ["seed"] = 0L }, + }); + + Assert.Single(response.Choices); + Assert.Contains("999", response.Message.Text); + Assert.False(didCallIrrelevantTool); + } + + private sealed class AssertNoToolsDefinedChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) + { + public override Task CompleteAsync( + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Assert.Null(options?.Tools); + return base.CompleteAsync(chatMessages, options, cancellationToken); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs new file mode 100644 index 00000000000..b09947337ed --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs @@ -0,0 +1,464 @@ +// 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.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(null!)); + Assert.Throws("modelId", () => new OllamaChatClient(new("http://localhost"), " ")); + } + + [Fact] + public void GetService_SuccessfullyReturnsUnderlyingClient() + { + using OllamaChatClient client = new(new("http://localhost")); + + Assert.Same(client, client.GetService()); + Assert.Same(client, client.GetService()); + + using IChatClient pipeline = new ChatClientBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Use(client); + + 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 AsChatClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + using IChatClient chatClient = new OllamaChatClient(endpoint, model); + Assert.Equal("ollama", chatClient.Metadata.ProviderName); + Assert.Equal(endpoint, chatClient.Metadata.ProviderUri); + Assert.Equal(model, chatClient.Metadata.ModelId); + } + + [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(new("http://localhost:11434"), "llama3.1", httpClient); + var response = await client.CompleteAsync("hello", new() + { + MaxOutputTokens = 10, + Temperature = 0.5f, + }); + Assert.NotNull(response); + + Assert.Equal("Hello! How are you today? Is there something", response.Message.Text); + Assert.Single(response.Message.Contents); + Assert.Equal(ChatRole.Assistant, response.Message.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(new("http://localhost:11434"), "llama3.1", httpClient); + + List updates = []; + await foreach (var update in client.CompleteStreamingAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + })) + { + 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.Equal(i < updates.Count - 1 ? 1 : 2, updates[i].Contents.Count); + Assert.Equal(ChatRole.Assistant, updates[i].Role); + Assert.All(updates[i].Contents, u => Assert.Equal("llama3.1", u.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); + } + + [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(new("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.CompleteAsync(messages, new() + { + ModelId = "llama3.1", + Temperature = 0.25f, + FrequencyPenalty = 0.75f, + PresencePenalty = 0.5f, + StopSequences = ["great"], + AdditionalProperties = new() { ["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.Message.Text)); + Assert.Single(response.Message.Contents); + Assert.Equal(ChatRole.Assistant, response.Message.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(new("http://localhost:11434"), "llama3.1", httpClient) + { + ToolCallJsonSerializerOptions = TestJsonSerializerContext.Default.Options, + }; + + var response = await client.CompleteAsync("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.Null(response.Message.Text); + Assert.Equal("llama3.1", response.ModelId); + Assert.Equal(ChatRole.Assistant, response.Message.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.Choices); + Assert.Single(response.Message.Contents); + FunctionCallContent fcc = Assert.IsType(response.Message.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(new("http://localhost:11434"), "llama3.1", httpClient) + { + ToolCallJsonSerializerOptions = TestJsonSerializerContext.Default.Options, + }; + + var response = await client.CompleteAsync( + [ + new(ChatRole.User, "How old is Alice?"), + new(ChatRole.Assistant, [new FunctionCallContent("abcd1234", "GetPersonAge", new Dictionary { ["personName"] = "Alice" })]), + new(ChatRole.Tool, [new FunctionResultContent("abcd1234", "GetPersonAge", 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.Message.Text); + Assert.Equal("llama3.1", response.ModelId); + Assert.Equal(ChatRole.Assistant, response.Message.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 new file mode 100644 index 00000000000..4333cbde636 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs @@ -0,0 +1,14 @@ +// 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; + +public class OllamaEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests +{ + protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => + IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? + new OllamaEmbeddingGenerator(endpoint, "all-minilm") : + null; +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs new file mode 100644 index 00000000000..205398c9a1c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.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.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(null!)); + Assert.Throws("modelId", () => new OllamaEmbeddingGenerator(new("http://localhost"), " ")); + } + + [Fact] + public void GetService_SuccessfullyReturnsUnderlyingClient() + { + using OllamaEmbeddingGenerator generator = new(new("http://localhost")); + + Assert.Same(generator, generator.GetService()); + Assert.Same(generator, generator.GetService>>()); + + using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Use(generator); + + Assert.NotNull(pipeline.GetService>>()); + Assert.NotNull(pipeline.GetService>>()); + Assert.NotNull(pipeline.GetService>>()); + + Assert.Same(generator, pipeline.GetService()); + Assert.IsType>>(pipeline.GetService>>()); + } + + [Fact] + public void AsEmbeddingGenerator_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + using IEmbeddingGenerator> chatClient = new OllamaEmbeddingGenerator(endpoint, model); + Assert.Equal("ollama", chatClient.Metadata.ProviderName); + Assert.Equal(endpoint, chatClient.Metadata.ProviderUri); + Assert.Equal(model, chatClient.Metadata.ModelId); + } + + [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(new("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 new file mode 100644 index 00000000000..49560a9c451 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +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.OpenAI.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs new file mode 100644 index 00000000000..da60e62061f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using Azure.AI.OpenAI; +using OpenAI; + +namespace Microsoft.Extensions.AI; + +/// Shared utility methods for integration tests. +internal static class IntegrationTestHelpers +{ + /// Gets an to use for testing, or null if the associated tests should be disabled. + public static OpenAIClient? GetOpenAIClient() + { + string? apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"); + + if (apiKey is not null) + { + if (string.Equals(Environment.GetEnvironmentVariable("OPENAI_MODE"), "AzureOpenAI", StringComparison.OrdinalIgnoreCase)) + { + var endpoint = Environment.GetEnvironmentVariable("OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("To use AzureOpenAI, set a value for OPENAI_ENDPOINT"); + return new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey)); + } + else + { + return new OpenAIClient(apiKey); + } + } + + return null; + } +} 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 new file mode 100644 index 00000000000..0ef40e12df3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj @@ -0,0 +1,26 @@ + + + Microsoft.Extensions.AI + Unit tests for Microsoft.Extensions.AI.OpenAI + + + + true + + + + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs new file mode 100644 index 00000000000..c82e1abc860 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs @@ -0,0 +1,13 @@ +// 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; + +public class OpenAIChatClientIntegrationTests : ChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetOpenAIClient() + ?.AsChatClient(Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL") ?? "gpt-4o-mini"); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs new file mode 100644 index 00000000000..f19a19f3ce8 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -0,0 +1,608 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using OpenAI; +using OpenAI.Chat; +using Xunit; + +#pragma warning disable S103 // Lines should not be too long + +namespace Microsoft.Extensions.AI; + +public class OpenAIChatClientTests +{ + [Fact] + public void Ctor_InvalidArgs_Throws() + { + Assert.Throws("openAIClient", () => new OpenAIChatClient(null!, "model")); + Assert.Throws("chatClient", () => new OpenAIChatClient(null!)); + + OpenAIClient openAIClient = new("key"); + Assert.Throws("modelId", () => new OpenAIChatClient(openAIClient, null!)); + Assert.Throws("modelId", () => new OpenAIChatClient(openAIClient, "")); + Assert.Throws("modelId", () => new OpenAIChatClient(openAIClient, " ")); + } + + [Fact] + public void AsChatClient_InvalidArgs_Throws() + { + Assert.Throws("openAIClient", () => ((OpenAIClient)null!).AsChatClient("model")); + Assert.Throws("chatClient", () => ((ChatClient)null!).AsChatClient()); + + OpenAIClient client = new("key"); + Assert.Throws("modelId", () => client.AsChatClient(null!)); + Assert.Throws("modelId", () => client.AsChatClient(" ")); + } + + [Fact] + public void AsChatClient_OpenAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + OpenAIClient client = new(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + + IChatClient chatClient = client.AsChatClient(model); + Assert.Equal("openai", chatClient.Metadata.ProviderName); + Assert.Equal(endpoint, chatClient.Metadata.ProviderUri); + Assert.Equal(model, chatClient.Metadata.ModelId); + + chatClient = client.GetChatClient(model).AsChatClient(); + Assert.Equal("openai", chatClient.Metadata.ProviderName); + Assert.Equal(endpoint, chatClient.Metadata.ProviderUri); + Assert.Equal(model, chatClient.Metadata.ModelId); + } + + [Fact] + public void AsChatClient_AzureOpenAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + AzureOpenAIClient client = new(endpoint, new ApiKeyCredential("key")); + + IChatClient chatClient = client.AsChatClient(model); + Assert.Equal("azureopenai", chatClient.Metadata.ProviderName); + Assert.Equal(endpoint, chatClient.Metadata.ProviderUri); + Assert.Equal(model, chatClient.Metadata.ModelId); + + chatClient = client.GetChatClient(model).AsChatClient(); + Assert.Equal("azureopenai", chatClient.Metadata.ProviderName); + Assert.Equal(endpoint, chatClient.Metadata.ProviderUri); + Assert.Equal(model, chatClient.Metadata.ModelId); + } + + [Fact] + public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() + { + OpenAIClient openAIClient = new(new ApiKeyCredential("key")); + IChatClient chatClient = openAIClient.AsChatClient("model"); + + Assert.Same(chatClient, chatClient.GetService()); + Assert.Same(chatClient, chatClient.GetService()); + + Assert.Same(openAIClient, chatClient.GetService()); + + Assert.NotNull(chatClient.GetService()); + + using IChatClient pipeline = new ChatClientBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Use(chatClient); + + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + + Assert.Same(openAIClient, pipeline.GetService()); + Assert.IsType(pipeline.GetService()); + } + + [Fact] + public void GetService_ChatClient_SuccessfullyReturnsUnderlyingClient() + { + ChatClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetChatClient("model"); + IChatClient chatClient = openAIClient.AsChatClient(); + + Assert.Same(chatClient, chatClient.GetService()); + Assert.Same(openAIClient, chatClient.GetService()); + + using IChatClient pipeline = new ChatClientBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Use(chatClient); + + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + + Assert.Same(openAIClient, pipeline.GetService()); + Assert.IsType(pipeline.GetService()); + } + + [Fact] + public async Task BasicRequestResponse_NonStreaming() + { + const string Input = """ + {"messages":[{"role":"user","content":"hello"}],"model":"gpt-4o-mini","max_completion_tokens":10,"temperature":0.5} + """; + + 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" + } + ], + "usage": { + "prompt_tokens": 8, + "completion_tokens": 9, + "total_tokens": 17, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.CompleteAsync("hello", new() + { + MaxOutputTokens = 10, + Temperature = 0.5f, + }); + Assert.NotNull(response); + + Assert.Equal("chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", response.CompletionId); + Assert.Equal("Hello! How can I assist you today?", response.Message.Text); + Assert.Single(response.Message.Contents); + Assert.Equal(ChatRole.Assistant, response.Message.Role); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_727_888_631), response.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + + Assert.NotNull(response.Usage); + Assert.Equal(8, response.Usage.InputTokenCount); + Assert.Equal(9, response.Usage.OutputTokenCount); + Assert.Equal(17, response.Usage.TotalTokenCount); + Assert.NotNull(response.Usage.AdditionalProperties); + + Assert.NotNull(response.AdditionalProperties); + Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); + } + + [Fact] + public async Task BasicRequestResponse_Streaming() + { + const string Input = """ + {"messages":[{"role":"user","content":"hello"}],"model":"gpt-4o-mini","max_completion_tokens":20,"stream":true,"stream_options":{"include_usage":true},"temperature":0.5} + """; + + const string Output = """ + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" How"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" can"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" assist"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":" today"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":8,"completion_tokens":9,"total_tokens":17,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}} + + data: [DONE] + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.CompleteStreamingAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + })) + { + updates.Add(update); + } + + Assert.Equal("Hello! How can I assist you today?", string.Concat(updates.Select(u => u.Text))); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_727_889_370); + Assert.Equal(12, updates.Count); + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK", updates[i].CompletionId); + Assert.Equal(createdAt, updates[i].CreatedAt); + Assert.All(updates[i].Contents, u => Assert.Equal("gpt-4o-mini-2024-07-18", u.ModelId)); + Assert.Equal(ChatRole.Assistant, updates[i].Role); + Assert.NotNull(updates[i].AdditionalProperties); + Assert.Equal("fp_f85bea6784", updates[i].AdditionalProperties![nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); + Assert.Equal(i == 10 ? 0 : 1, updates[i].Contents.Count); + Assert.Equal(i < 10 ? null : ChatFinishReason.Stop, updates[i].FinishReason); + } + + UsageContent usage = updates.SelectMany(u => u.Contents).OfType().Single(); + Assert.Equal(8, usage.Details.InputTokenCount); + Assert.Equal(9, usage.Details.OutputTokenCount); + Assert.Equal(17, usage.Details.TotalTokenCount); + Assert.NotNull(usage.Details.AdditionalProperties); + Assert.Equal(new Dictionary { [nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)] = 0 }, usage.Details.AdditionalProperties[nameof(ChatTokenUsage.OutputTokenDetails)]); + } + + [Fact] + public async Task MultipleMessages_NonStreaming() + { + const string Input = """ + { + "messages": [ + { + "role": "system", + "content": "You are a really nice friend." + }, + { + "role": "user", + "content": "hello!" + }, + { + "role": "assistant", + "content": "hi, how are you?" + }, + { + "role": "user", + "content": "i\u0027m good. how are you?" + } + ], + "model": "gpt-4o-mini", + "frequency_penalty": 0.75, + "presence_penalty": 0.5, + "seed":42, + "stop": [ + "great" + ], + "temperature": 0.25 + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I’m doing well, thank you! What’s on your mind today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 42, + "completion_tokens": 15, + "total_tokens": 57, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List messages = + [ + new(ChatRole.System, "You are a really nice friend."), + new(ChatRole.User, "hello!"), + new(ChatRole.Assistant, "hi, how are you?"), + new(ChatRole.User, "i'm good. how are you?"), + ]; + + var response = await client.CompleteAsync(messages, new() + { + Temperature = 0.25f, + FrequencyPenalty = 0.75f, + PresencePenalty = 0.5f, + StopSequences = ["great"], + AdditionalProperties = new() { ["seed"] = 42 }, + }); + Assert.NotNull(response); + + Assert.Equal("chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", response.CompletionId); + Assert.Equal("I’m doing well, thank you! What’s on your mind today?", response.Message.Text); + Assert.Single(response.Message.Contents); + Assert.Equal(ChatRole.Assistant, response.Message.Role); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_727_894_187), response.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + + Assert.NotNull(response.Usage); + Assert.Equal(42, response.Usage.InputTokenCount); + Assert.Equal(15, response.Usage.OutputTokenCount); + Assert.Equal(57, response.Usage.TotalTokenCount); + Assert.NotNull(response.Usage.AdditionalProperties); + + Assert.NotNull(response.AdditionalProperties); + Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); + } + + [Fact] + public async Task FunctionCallContent_NonStreaming() + { + const string Input = """ + { + "messages": [ + { + "role": "user", + "content": "How old is Alice?" + } + ], + "model": "gpt-4o-mini", + "tools": [ + { + "type": "function", + "function": { + "description": "Gets the age of the specified person.", + "name": "GetPersonAge", + "parameters": { + "type": "object", + "required": [ + "personName" + ], + "properties": { + "personName": { + "description": "The person whose age is being requested", + "type": "string" + } + } + }, + "strict": false + } + } + ], + "tool_choice": "auto" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADydKhrSKEBWJ8gy0KCIU74rN3Hmk", + "object": "chat.completion", + "created": 1727894702, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_8qbINM045wlmKZt9bVJgwAym", + "type": "function", + "function": { + "name": "GetPersonAge", + "arguments": "{\"personName\":\"Alice\"}" + } + } + ], + "refusal": null + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 61, + "completion_tokens": 16, + "total_tokens": 77, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.CompleteAsync("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.Null(response.Message.Text); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(ChatRole.Assistant, response.Message.Role); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_727_894_702), response.CreatedAt); + Assert.Equal(ChatFinishReason.ToolCalls, response.FinishReason); + Assert.NotNull(response.Usage); + Assert.Equal(61, response.Usage.InputTokenCount); + Assert.Equal(16, response.Usage.OutputTokenCount); + Assert.Equal(77, response.Usage.TotalTokenCount); + + Assert.Single(response.Choices); + Assert.Single(response.Message.Contents); + FunctionCallContent fcc = Assert.IsType(response.Message.Contents[0]); + Assert.Equal("GetPersonAge", fcc.Name); + AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); + + Assert.NotNull(response.AdditionalProperties); + Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); + } + + [Fact] + public async Task FunctionCallContent_Streaming() + { + const string Input = """ + { + "messages": [ + { + "role": "user", + "content": "How old is Alice?" + } + ], + "model": "gpt-4o-mini", + "stream": true, + "stream_options": { + "include_usage": true + }, + "tools": [ + { + "type": "function", + "function": { + "description": "Gets the age of the specified person.", + "name": "GetPersonAge", + "parameters": { + "type": "object", + "required": [ + "personName" + ], + "properties": { + "personName": { + "description": "The person whose age is being requested", + "type": "string" + } + } + }, + "strict": false + } + } + ], + "tool_choice": "auto" + } + """; + + const string Output = """ + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_F9ZaqPWo69u0urxAhVt8meDW","type":"function","function":{"name":"GetPersonAge","arguments":""}}],"refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"person"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Name"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Alice"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":null} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":61,"completion_tokens":16,"total_tokens":77,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}} + + data: [DONE] + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.CompleteStreamingAsync("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.")], + })) + { + updates.Add(update); + } + + Assert.Equal("", string.Concat(updates.Select(u => u.Text))); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_727_895_263); + Assert.Equal(10, updates.Count); + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl", updates[i].CompletionId); + Assert.Equal(createdAt, updates[i].CreatedAt); + Assert.All(updates[i].Contents, u => Assert.Equal("gpt-4o-mini-2024-07-18", u.ModelId)); + Assert.Equal(ChatRole.Assistant, updates[i].Role); + Assert.NotNull(updates[i].AdditionalProperties); + Assert.Equal("fp_f85bea6784", updates[i].AdditionalProperties![nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); + Assert.Equal(i < 7 ? null : ChatFinishReason.ToolCalls, updates[i].FinishReason); + } + + FunctionCallContent fcc = Assert.IsType(Assert.Single(updates[updates.Count - 1].Contents)); + Assert.Equal("call_F9ZaqPWo69u0urxAhVt8meDW", fcc.CallId); + Assert.Equal("GetPersonAge", fcc.Name); + AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); + + UsageContent usage = updates.SelectMany(u => u.Contents).OfType().Single(); + Assert.Equal(61, usage.Details.InputTokenCount); + Assert.Equal(16, usage.Details.OutputTokenCount); + Assert.Equal(77, usage.Details.TotalTokenCount); + Assert.NotNull(usage.Details.AdditionalProperties); + Assert.Equal(new Dictionary { [nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)] = 0 }, usage.Details.AdditionalProperties[nameof(ChatTokenUsage.OutputTokenDetails)]); + } + + private static IChatClient CreateChatClient(HttpClient httpClient, string modelId) => + new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) + .AsChatClient(modelId); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs new file mode 100644 index 00000000000..38283e2687b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs @@ -0,0 +1,13 @@ +// 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; + +public class OpenAIEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests +{ + protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => + IntegrationTestHelpers.GetOpenAIClient() + ?.AsEmbeddingGenerator(Environment.GetEnvironmentVariable("OPENAI_EMBEDDING_MODEL") ?? "text-embedding-3-small"); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs new file mode 100644 index 00000000000..d08cf295a4b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -0,0 +1,187 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net.Http; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using OpenAI; +using OpenAI.Embeddings; +using Xunit; + +#pragma warning disable S103 // Lines should not be too long + +namespace Microsoft.Extensions.AI; + +public class OpenAIEmbeddingGeneratorTests +{ + [Fact] + public void Ctor_InvalidArgs_Throws() + { + Assert.Throws("openAIClient", () => new OpenAIEmbeddingGenerator(null!, "model")); + Assert.Throws("embeddingClient", () => new OpenAIEmbeddingGenerator(null!)); + + OpenAIClient openAIClient = new("key"); + Assert.Throws("modelId", () => new OpenAIEmbeddingGenerator(openAIClient, null!)); + Assert.Throws("modelId", () => new OpenAIEmbeddingGenerator(openAIClient, "")); + Assert.Throws("modelId", () => new OpenAIEmbeddingGenerator(openAIClient, " ")); + } + + [Fact] + public void AsEmbeddingGenerator_InvalidArgs_Throws() + { + Assert.Throws("openAIClient", () => ((OpenAIClient)null!).AsEmbeddingGenerator("model")); + Assert.Throws("embeddingClient", () => ((EmbeddingClient)null!).AsEmbeddingGenerator()); + + OpenAIClient client = new("key"); + Assert.Throws("modelId", () => client.AsEmbeddingGenerator(null!)); + Assert.Throws("modelId", () => client.AsEmbeddingGenerator(" ")); + } + + [Fact] + public void AsEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + OpenAIClient client = new(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + + IEmbeddingGenerator> embeddingGenerator = client.AsEmbeddingGenerator(model); + Assert.Equal("openai", embeddingGenerator.Metadata.ProviderName); + Assert.Equal(endpoint, embeddingGenerator.Metadata.ProviderUri); + Assert.Equal(model, embeddingGenerator.Metadata.ModelId); + + embeddingGenerator = client.GetEmbeddingClient(model).AsEmbeddingGenerator(); + Assert.Equal("openai", embeddingGenerator.Metadata.ProviderName); + Assert.Equal(endpoint, embeddingGenerator.Metadata.ProviderUri); + Assert.Equal(model, embeddingGenerator.Metadata.ModelId); + } + + [Fact] + public void AsEmbeddingGenerator_AzureOpenAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + AzureOpenAIClient client = new(endpoint, new ApiKeyCredential("key")); + + IEmbeddingGenerator> embeddingGenerator = client.AsEmbeddingGenerator(model); + Assert.Equal("azureopenai", embeddingGenerator.Metadata.ProviderName); + Assert.Equal(endpoint, embeddingGenerator.Metadata.ProviderUri); + Assert.Equal(model, embeddingGenerator.Metadata.ModelId); + + embeddingGenerator = client.GetEmbeddingClient(model).AsEmbeddingGenerator(); + Assert.Equal("azureopenai", embeddingGenerator.Metadata.ProviderName); + Assert.Equal(endpoint, embeddingGenerator.Metadata.ProviderUri); + Assert.Equal(model, embeddingGenerator.Metadata.ModelId); + } + + [Fact] + public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() + { + OpenAIClient openAIClient = new(new ApiKeyCredential("key")); + IEmbeddingGenerator> embeddingGenerator = openAIClient.AsEmbeddingGenerator("model"); + + Assert.Same(embeddingGenerator, embeddingGenerator.GetService>>()); + Assert.Same(embeddingGenerator, embeddingGenerator.GetService()); + + Assert.Same(openAIClient, embeddingGenerator.GetService()); + + Assert.NotNull(embeddingGenerator.GetService()); + + using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Use(embeddingGenerator); + + Assert.NotNull(pipeline.GetService>>()); + Assert.NotNull(pipeline.GetService>>()); + Assert.NotNull(pipeline.GetService>>()); + + Assert.Same(openAIClient, pipeline.GetService()); + Assert.IsType>>(pipeline.GetService>>()); + } + + [Fact] + public void GetService_EmbeddingClient_SuccessfullyReturnsUnderlyingClient() + { + EmbeddingClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetEmbeddingClient("model"); + IEmbeddingGenerator> embeddingGenerator = openAIClient.AsEmbeddingGenerator(); + + Assert.Same(embeddingGenerator, embeddingGenerator.GetService>>()); + Assert.Same(openAIClient, embeddingGenerator.GetService()); + + using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Use(embeddingGenerator); + + Assert.NotNull(pipeline.GetService>>()); + Assert.NotNull(pipeline.GetService>>()); + Assert.NotNull(pipeline.GetService>>()); + + Assert.Same(openAIClient, pipeline.GetService()); + Assert.IsType>>(pipeline.GetService>>()); + } + + [Fact] + public async Task GetEmbeddingsAsync_ExpectedRequestResponse() + { + const string Input = """ + {"input":["hello, world!","red, white, blue"],"model":"text-embedding-3-small","encoding_format":"base64"} + """; + + const string Output = """ + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": "qjH+vMcj07wP1+U7kbwjOv4cwLyL3iy9DkgpvCkBQD0bthW98o6SvMMwmTrQRQa9r7b1uy4tuLzssJs7jZspPe0JG70KJy89ae4fPNLUwjytoHk9BX/1OlXCfTzc07M8JAMIPU7cibsUJiC8pTNGPWUbJztfwW69oNwOPQIQ+rwm60M7oAfOvDMAsTxb+fM77WIaPIverDqcu5S84f+rvFyr8rxqoB686/4cPVnj9ztLHw29mJqaPAhH8Lz/db86qga/PGhnYD1WST28YgWru1AdRTz/db899PIPPBzBE720ie47ujymPbh/Kb0scLs8V1Q7PGIFqzwVMR48xp+UOhNGYTxfwW67CaDvvOeEI7tgc228uQNoPXrLBztd2TI9HRqTvLuVJbytoPm8YVMsOvi6irzweJY7/WpBvI5NKL040ym95ccmPAfj8rxJCZG9bsGYvJkpVzszp7G8wOxcu6/ZN7xXrTo7Q90YvGTtZjz/SgA8RWxVPL/hXjynl8O8ZzGjvHK0Uj0dRVI954QjvaqKfTxmUeS8Abf6O0RhV7tr+R098rnRPAju8DtoiiK95SCmvGV0pjwQMOW9wJPdPPutxDxYivi8NLKvPI3pKj3UDYE9Fg5cvQsyrTz+HEC9uuMmPMEaHbzJ4E8778YXvVDERb2cFBS9tsIsPLU7bT3+R/+8b55WPLhRaTzsgls9Nb2tuhNG4btlzSW9Y7cpvO1iGr0lh0a8u8BkvadJQj24f6k9J51CvbAPdbwCEHq8CicvvIKROr0ESbg7GMvYPE6OCLxS2sG7/WrBPOzbWj3uP1i9TVXKPPJg0rtp7h87TSqLPCmowLxrfdy8XbbwPG06WT33jEo9uxlkvcQN17tAmVy8h72yPEdMFLz4Ewo7BPs2va35eLynScI8WpV2PENW2bwQBSa9lSufu32+wTwl4MU8vohfvRyT07ylCIe8dHHPPPg+ST0Ooag8EsIiO9F7w7ylM0Y7dfgOPADaPLwX7hq7iG8xPDW9Lb1Q8oU98twTPYDUvTomwIQ8akcfvUhXkj3mK6Q8syXxvAMb+DwfMI87bsGYPGUbJ71GHtS8XbbwvFQ+P70f14+7Uq+CPSXgxbvHfFK9icgwPQsEbbwm60O9EpRiPDjTKb3uFJm7p/BCPazDuzxh+iy8Xj2wvBqrl71a7nU9guq5PYNDOb1X2Pk8raD5u+bSpLsMD2u7C9ktPVS6gDzyjhI9vl2gPNO0AT0/vJ68XQTyvMMCWbubYhU9rzK3vLhRaToSlOK6qYIAvQAovrsa1la8CEdwPKOkCT1jEKm8Y7epvOv+HLsoJII704ZBPXbVTDubjVQ8aRnfOvspBr2imYs8MDi2vPFVVDxSrwK9hac2PYverLyxGnO9nqNQvfVLD71UEP+8tDDvurN+8Lzkbqc6tsKsu5WvXTtDKxo72b03PdDshryvXfY81JE/vLYbLL2Fp7Y7JbUGPEQ2GLyagla7fAxDPaVhhrxu7Ne7wzAZPOxXHDx5nUe9s35wPHcOizx1fM26FTGePAsEbbzzQBE9zCQMPW6TWDygucy8zPZLPM2oSjzfmy48EF4lvUttDj3NL4q8WIp4PRoEFzxKFA89uKpou9H3BDvK6009a33cPLq15rzv8VY9AQX8O1gxebzjCqo7EeJjPaA1DrxoZ2C65tIkvS0iOjxln2W8o0sKPMPXGb3Ak908cxhQvR8wDzzN1gq8DnNovMZGFbwUJiA9moJWPBl9VzkVA148TrlHO/nFCL1f7y68xe2VPIROtzvCJRu88YMUvaUzRj1qR5+7e6jFPGyrHL3/SgC9GMtYPJcT27yqMX688YOUO32+QT18iAS9cdeUPFbN+zvlx6a83d6xOzQLL7sZJNi8mSnXOuqan7uqin09CievvPw0hLyuq/c866Udu4T1t7wBXnu7zQFKvE5gyDxhUyw8qzx8vIrTLr0Kq+26TgdJPWmVoDzOiIk8aDwhPVug9Lq6iie9iSEwvOKxqjwMiyy7E59gPepMnjth+iw9ntGQOyDijbw76SW9i96sO7qKJ7ybYhU8R/6Su+GmLLzsgtu7inovPRG3pLwZUpi7YzvoucrAjjwOSKm8uuOmvLbt67wKUu68XCc0vbd0Kz0LXWy8lHmgPAAoPjxRpAS99oHMvOlBoDprUh09teLtOxoEl7z0mRA89tpLvVQQ/zyjdkk9ZZ/lvHLikrw76SW82LI5vXyIBLzVnL06NyGrPPXPzTta7nW8FTEePSVcB73FGFU9SFcSPbzL4rtXrbo84lirvcd8Urw9/yG9+63EvPdhCz2rPPw8PPQjvbXibbuo+0C8oWtLPWVG5juL3qw71Zw9PMUY1Tk3yKu8WWq3vLnYKL25A+i8zH2LvMW/1bxDr1g8Cqvtu3pPRr0FrbU8vVKiO0LSGj1b+fM7Why2ux1FUjwhv0s89lYNPUbFVLzJ4M88t/hpvdpvNj0EzfY7gC29u0HyW7yv2Tc8dSPOvNhZurzrpR28jUIqPM0vijxyDdK8iBYyvZ0fkrxalXa9JeBFPO/GF71dBHK8X8FuPKnY/jpQmQY9S5jNPGBz7TrpQaA87/FWvUHyWzwCEPq78HiWOhfuGr0ltYY9I/iJPamCgLwLBO28jZupu38ivzuIbzG8Cfnuu0dMlLypKQG7BzxyvR5QULwCEHo8k8ehPUXoFjzPvka9MDi2vPsphjwjfMi854QjvcW/VbzO4Yg7Li04vL/h3jsaL9a5iG8xuybrwzz3YYu8Gw8VvVGkBD1UugA99MRPuCjLArzvxhc8XICzPFyrcr0gDU296h7eu8jV0TxNKos8lSufuqT9CD1oDmE8sqGyu2PiaLz6osY5YjBqPBAFJrwIlfG8PlihOBE74zzzQJG8r112vJPHobyrPPw7YawrPb5doLqtzrk7qHcCPVIoQzz5l0i81UM+vFd/eryaVxc9xA3XO/6YgbweJZG7W840PF0Ecj19ZUI8x1GTOtb1vDyDnLg8yxkOvOywGz0kqgg8fTqDvKlUQL3Bnlu992ELvZPHobybCZa82LK5vf2NgzwnnUK8YMzsPKOkiTxDr9g6la/duz3/IbusR/q8lmFcvFbN+zztCRu95nklPVKBwjwEJnY6V9j5PPK50bz6okY7R6UTPPnFiDwCafk8N8grO/gTCr1iiWm8AhB6vHHXlLyV3Z08vtZgPMDsXDsck9O7mdBXvRLCojzkbqe8XxpuvDSyLzu0MO87cxhQvd3eMbxtDxo9JKqIvB8CT72zrDC7s37wPHvWhbuXQZs8UlYDu7ef6rzsV5y8IkYLvUo/Tjz+R/88PrGgujSyrzxsBJy8P7yeO7f46byfKpA8cFDVPLygIzsdGpO77LCbvLSJ7rtgzOy7sA91O0hXkrwhO408XKvyvMUYVT2mPsQ8d+DKu9lkuLy+iF89xZSWPJFjpDwIlfE8bC9bPBE7Y7z/+f08W6B0PAc8crhmquO7RvOUPDybJLwlXAe9cuKSvMPXGbxK5s48sZY0O+4UmT1/Ij+8oNyOvPIH07tNKos8yTnPO2RpKDwRO+O7vl2gvKSvB7xGmpW7nD9TPZpXFzyXQRs9InHKurhR6bwb4VS8iiwuO3pPxrxeD3A8CfluO//OPr0MaOq8r112vAwP6zynHgM9T+cHPJuNVLzLRE07EmkjvWHX6rzBGh285G4nPe6Y17sCafm8//n9PJkpVzv9P4K7IWbMPCtlvTxHKVK8JNXHO/uCBblAFZ48xyPTvGaqY7wXlRs9EDDlPHcOizyNQiq9W3W1O7iq6LxwqdQ69MRPvSJGC7n3CIy8HOxSvSjLAryU0p87QJncvEoUjzsi7Qu9U4xAOwn5brzfm668Wu71uu002rw/Y588o6SJPFfY+Tyfg4+8u5WlPMDBnTzVnD08ljadu3sBxbzfm668n4OPO9VDvrz0mZC8kFimPNiyOT134Mo8vquhvDA4Njyjz0i7zVpJu1rudbwmksQ794xKuhN0ITz/zj68Vvu7unBQ1bv8NAS97FecOyxwOzs1ZC68AIG9PKLyCryvtvU8ntEQPBkkWD2xwfO7QfLbOhqIVTykVog7lSufvKOkiTwpqEA9/RFCvKxHejx3tYu74woqPMS0VzoMtuu8ViZ7PL8PH72+L2C81JE/vN3eMTwoywK9z5OHOx4lkTwGBrW8c5QRu4khMDyvBPc8nR8SvdlkuLw0si+9S8aNvCkBwLsXwFo7Od4nPbo8pryp2P68GfkYPKpfvjrsV5w6zuEIvbHB8zxnMSM9C9mtu1nj97zjYym8XFJzPAiVcTyNm6m7X5YvPJ8qED1l+OS8WTx3vGKJ6bt+F0G9jk2oPAR0dzwIR/A8umdlvNLUwjzI1dE7yuvNvBdnW7zdhTI9xkaVPCVcB70Mtus7G7aVPDchK7xuwRi8oDWOu/SZkLxOuUe8c5QRPLBo9Dz/+f07zS+KvNBFBr1n2CO8TKNLO4ZZNbym5US5HsyRvGi1YTwxnDO71vW8PM3WCr3E4he816e7O7QFML2asBa8jZspPSVcBzvjvCi9ZGmoPHV8zbyyobK830KvOgw9q7xzZtG7R6WTPMpnjzxj4mg8mrAWPS+GN7xoZ2C8tsKsOVMIAj1fli89Zc0lO00qCzz+R/87XKvyvLxy4zy52Cg9YjBqvW9F1zybjVS8mwmWvLvA5DymugU9DOQrPJWvXbvT38C8TrnHvLbt67sgiQ49e32GPPTETzv7goW7cKnUOoOcuLpG85S8CoCuO7ef6rkaqxe90tTCPJ8qkDvuuxk8FFFfPK9ddrtAbh08roC4PAnOrztV8D08jemquwR09ziL3iy7xkaVumVG5rygNQ69CfnuPGBzbTyE9Tc9Z9ijPK8yNzxgoa084woqu1F2RLwN76m7hrI0vf7xgLwaXRY6JmeFO68ytzrrpR29XbZwPYI4uzvkFai8qHcCPRCJ5DxKFI+7dHHPPE65xzxvnta8BPs2vWaq4zwrvjy8tDDvvEq7D7076SU9q+N8PAsyLTxb+XM9xZQWPP7ufzxsXZu6BEk4vGXNJbwBXvu8xA3XO8lcEbuuJzk8GEeavGnun7sMPSs9ITsNu1yr8roj+Ik8To6IvKjQgbwIwzG8wqlZvDfIK7xln2W8B+Pyu1HPw7sBjDs9Ba01PGSU57w/Yx867FecPFdUu7w2b6w7X5avvA8l57ypKQE9oGBNPeyC27vGytM828i1PP9KAD2/4V68eZ1HvDHqtDvR94Q6UwgCPLMlcbz+w0C8HwJPu/I1k7yZ/pe8aLXhPHYDDT28oKO8p2wEvdVDvrxh+qy8WDF5vJBYpjpaR3U8vgQhPNItwrsJoG88UaQEu3e1C7yagtY6HOzSOw9+5ryYTBk9q+N8POMKqrwoywI9DLZrPCN8SDxYivi8b3MXPf/OvruvBHc8M6exvA3vKbxz7RA8Fdieu4rTrrwFVDa8Vvu7PF0Ecjs6N6e8BzzyPP/Ovrv2rww9t59qvEoUDz3HUZO7UJkGPRigmbz/+X28qjH+u3jACbxlzaW7DA9rvFLawbwLBO2547yoO1t1NTr1pI68Vs37PAI+Ojx8s8O8xnHUvPg+yTwLBO26ybUQPfUoTTw76SU8i96sPKWMRbwUqt46pj7EPGX4ZL3ILtG8AV77vM0BSjzKZ488CByxvIWnNjyIFrI83CwzPN2FsjzHUZO8rzK3O+iPIbyGCzQ98NGVuxpdlrxhrKs8hQC2vFWXvjsCaXm8oRJMPHyIBLz+HMA8W/nzvHkZCb0pqMC87m0YPCu+vDsM5Ks8VnR8vG0Pmrt0yk48y3KNvKcegzwGMXS9xZQWPDYWrTxxAtQ7IWZMPU4Hybw89CO8/eaCPPMSUTxuk9i8WAY6vGfYozsQMGW8Li24vI+mJzxKFI88HwJPPFru9btRz8O6L9+2u29F1zwC5bq7RGHXvMtyjbr5bIm7V626uxsPlTv1KE29UB3FPMwkDDupggC8SQkRvH4XQT1cJ7Q8nvzPvKsRvTu9+SI8JbUGuiP4iTx460i99JkQPNF7Qz26Dma8u+4kvHO/0LyzfvA8EIlkPUPdmLpmUWS8uxnku8f4E72ruL27BzxyvKeXwz1plSC8gpG6vEQ2mLvtYho91Zy9vLvA5DtnXGK7sZY0uyu+PLwXlZu8GquXvE2uSb0ezBG8wn6au470KD1Abh28YMzsvPQdT7xKP867Xg/wO81aSb0IarK7SY1PO5EKJTsMi6y8cH4VvcXtlbwdGhM8xTsXPQvZLbxgzOw7Pf8hPRsPlbzDMJm8ZGmoPM1aSb0HEbO8PPQjvX5wwDwQXiW9wlDaO7SJ7jxFE9a8FTEePG5omTvPkwc8vtZgux9bzrmwD3W8U2EBPAVUNj0hlIw7comTPAEF/DvKwI68YKGtPJ78Tz1boHQ9sOS1vHiSSTlVG307HsyRPHEwFDxQmQY8CaBvvB0aE70PfuY8+neHvHOUET3ssBu7+tCGPJl3WDx4wAk9d1yMPOqanzwGBjW8ZialPB7MEby1O+07J0RDu4yQq7xpGV88ZXQmPc3WCruRCqU8Xbbwu+0JG7kXGVq8SY1PvKblxDv/oH68r7Z1OynWgDklh0a8E/hfPBCJZL31/Y08sD21vA9+Zjy6DmY82WQ4PAJp+TxHTJQ8JKoIvUBunbwgDc26BzxyvVUb/bz+w8A8Wu51u8guUbyHZLM8Iu0LvJqCVj3nhKO96kwevVDyBb3UDYG79zNLO7KhMj1IgtE83NOzO0f+krw89CM9z5OHuz+OXj2TxyE8wOzcPP91v7zUZgA8DyVnvILqOTzn3aI8j/+mO8xPyzt1UQ48+R4IvQnOrzt1I067QtKau9vINb1+7AE8sA/1uy7UOLzpQSC8dqoNPSnWgDsJoO+8ANo8vfDRlbwefpC89wgMPI1CKrrYsrm78mBSvFFLBb1Pa0a8s1MxPHbVzLw+WCG9kbyjvNt6tLwfMA+8HwLPvGO3qTyyobK8DcFpPInIsLwXGdq7nBSUPGdc4ryTx6G8T+eHPBxolDvIqhK8rqv3u1fY+Tz3M0s9qNCBO/GDlL2N6Sq9XKtyPFMIgrw0Cy+7Y7epPLJzcrz/+X28la/du8MC2bwTn+C5YSXsvDneJzz/SoC8H9ePvHMY0Lx0nw+9lSsfvS3Jujz/SgC94rEqvQwP67zd3rE83NOzPKvj/DyYmpo8h2SzvF8abjye0ZC8vSRivCKfijs/vJ48NAuvvFIoQzzFGFU9dtVMPa2g+TtpGd88Uv2DO3kZiTwA2rw79f2Nu1ugdDx0nw+8di7MvIrTrjz08g+8j6anvGH6LLxQ8oW8LBc8Pf0/Ajxl+OQ8SQkRPYrTrrzyNRM8GquXu9ItQjz1Sw87C9mtuxXYnrwDl7m87Y1ZO2ChrbyhQIy4EsIiPWpHHz0inwo7teJtPJ0fEroHPPK7fp4APV/B7rwwODa8L4Y3OiaSxLsBBfw7RI8XvP5H/zxVlz68n1VPvEBuHbwTzSA8fOEDvV49sDs2b6y8mf6XPMVm1jvjvCg8ETvjPEQ2GLxK5s47Q92YuxOfYLyod4K8EDDlPHAlFj1zGFC8pWGGPE65R7wBMzy8nJjSvLoO5rwwkbU7Eu3hvLOsMDyyobI6YHNtPKs8fLzXp7s6AV57PV49MLsVMR68+4KFPIkhMLxeaG87mXdYulyAMzzQRQY9ljadu3YDDby7GWS7phOFPEJ5mzq6tea6Eu1hPJjzmTz+R388di5MvJn+F7wi7Qs8K768PFnj9zu5MSi8Gl2WvJfomzxHd1O8vw8fvONjqbxuaBk980ARPSNRiTwLMi272Fk6vDGcs7z60Ia8vX1hOzvppbuKLK48jZspvZkpV7pWJns7G7YVPdPfwLyruL08FFHfu7ZprbwT+N84+1TFPGpHn7y9JOI8xe2Vu08SR7zs29o8/RFCPCbAhDzfQi89OpCmvL194boeJZE8kQqlvES6VjrzEtE7eGeKu2kZX71rfdw8D6wmu6Y+xLzJXJE8DnPovJrbVbvkFai8KX0Bvfr7RbuXbNq8Gw+VPRCJ5LyA1D28uQPoPLygo7xENpi8/RHCvEOv2DwRtyS9o0uKPNshNbvmeSU8IyPJvCedQjy7GWQ8Wkf1vGKJ6bztYho8vHLju5cT2zzKZw+88jWTvFb7uznYCzm8" + }, + { + "object": "embedding", + "index": 1, + "embedding": "eyfbu150UDkC6hQ9ip9oPG7jWDw3AOm8DQlcvFiY5Lt3Z6W8BLPPOV0uOz3FlQk8h5AYvH6Aobv0z/E8nOQRvHI8H7rQA+s8F6X9vPplyDzuZ1u8T2cTvAUeoDt0v0Q9/xx5vOhqlT1EgXu8zfQavTK0CDxRxX08v3MIPAY29bzIpFm8bGAzvQkkazxCciu8mjyxvIK0rDx6mzC7Eqg3O8H2rTz9vo482RNiPUYRB7xaQMU80h8hu8kPqrtyPB+8dvxUvfplSD21bJY8oQ8YPZbCEDvxegw9bTJzvYNlEj0h2q+9mw5xPQ5P8TyWwpA7rmvvO2Go27xw2tO6luNqO2pEfTztTwa7KnbRvAbw37vkEU89uKAhPGfvF7u6I8c8DPGGvB1gjzxU2K48+oqDPLCo/zsskoc8PUclvXCUvjzOpQC9qxaKO1iY5LyT9XS9ZNzmvI74Lr03azk93CYTvFJVCTzd+FK8lwgmvcMzPr00q4O9k46FvEx5HbyIqO083xSJvC7PFzy/lOK7HPW+PF2ikDxeAHu9QnIrvSz59rl/UmG8ZNzmu2b4nD3V31Y5aXK9O/2+jrxljUw8y9jkPGuvTTxX5/48u44XPXFFpDwAiEm8lcuVvX6h+zwe7Lm8SUUSPHmkNTu9Eb08cP8OvYgcw7xU2C49Wm4FPeV8H72AA8c7eH/6vBI0Yj3L2GQ8/0G0PHg5ZTvHjAS9fNhAPcE8wzws2By6RWAhvWTcZjz+1uM8H1eKvHdnJT0TWR29KcVrPdu7wrvMQzW9VhW/Ozo09LvFtuM8OlmvPO5GAT3eHY68zTqwvIhiWLs1w1i9sGJqPaurOb0s2Jy8Z++XOwAU9Lggb988vnyNvVfGpLypKBS8IouVO60NBb26r/G6w+0ovbVslrz+kE68MQOjOxdf6DvoRdo8Z4RHPCvhIT3e7009P4Q1PQ0JXDyD8Ty8/ZnTuhu4Lj3X1lG9sVnlvMxDNb3wySY9cUWkPNZKJ73qyP+8rS7fPNhBojwpxes8kt0fPM7rlbwYEE68zoBFvdrExzsMzEu9BflkvF0uu7zNFfW8UyfJPPSJ3LrEBf68+6JYvef/xDpAe7C8f5h2vPqKA7xUTAS9eDllPVK8eL0+GeW7654gPQuGNr3/+x69YajbPAehRTyc5BE8pfQIPMGwGL2QoA87iGJYPYXoN7s4sc69f1JhPdYEkjxgkIa6uxpCvHtMljtYvR88uCzMPBeEo7wm1/U8GBDOvBkHybwyG3i7aeaSvQzMyzy3e2a9xZUJvVSSmTu7SII8x4yEPKAYHTxUTIQ8lcsVO5x5QT3VDRe963llO4K0rLqI1i07DX0xvQv6CznrniA9nL9WPTvl2Tw6WS+8NcPYvEL+VbzZfrK9NDcuO4wBNL0jXVW980PHvNZKJz1Oti09StG8vIZTiDwu8PE8zP0fO9340juv1j890vFgvMFqAz2kHui7PNxUPQehxTzjGlQ9vcunPL+U4jyfrUw8R+NGPHQF2jtSdmO8mYtLvF50ULyT1Bo9ONaJPC1kx7woznC83xQJvUdv8byEXA29keaku6Qe6Ly+fA29kKAPOxLuzLxjxJG9JnCGur58jTws2Jy8CkmmO3pVm7uwqH87Eu7Mu/SJXL0IUis9MFI9vGnmEr1Oti09Z+8XvH1DkbwcaZS8NDcuvT0BkLyPNT89Haakuza607wv5+w81KLGO80VdT3MiUq8J4hbPHHRzrwr4aG8PSJqvJOOBT3t2zC8eBgLvXchkLymOp66y9jkPDdG/jw2ulO983GHPDvl2Tt+Ooy9NwDpOzZ0Pr3xegw7bhGZvEpd57s5YjS9Gk1evIbfMjxBwcW8NnQ+PMlVPzxR6ji9M8zdPImHk7wQsby8u0gCPXtMFr22YxE9Wm4FPaXPzbygGJ093bK9OuYtBTxyXfk8iYeTvNH65byk/Q29QO+FvKbGyLxCcqs9nL/WvPtcQ72XTjs8kt2fuhaNKDxqRH08KX9WPbmXnDtXDDo96GoVPVw3QL0eeGS8ayOjvAIL7zywQZC9at0NvUMjET1Q8707eTDgvIio7Tv60Jg87kYBOw50LLx7BgE96qclPUXsSz0nQkY5aDUtvQF/RD1bZQC73fjSPHgYCzyPNT+9q315vbMvhjsvodc8tEdbPGcQ8jz8U768cYs5PIwBtL38x5M9PtPPvIex8jzfFIk9vsIivLsaQj2/uZ072y8YvSV5C7uoA9k8JA67PO5nWzvS8eC8av7nuxSWrbybpwE9f5h2vG3sXTmoA1k9sjiLvTBSPbxc8Sq9UpuePB+dHz2/cwg9BWS1vCrqJr2M3Pg86LAqPS/GEj3oRdq8GiyEvACISbuiJ+28FFAYuzBSvTzwDzy8K5uMvE5wmDpd6CW6dkJqPGlyvTwF2Iq9f1JhPSHarzwDdr88JXkLu4ADxzx5pDW7zqUAvdAoJj24wXs8doj/PH46jD2/2vc893fSuyxtTL0YnPg7IWbaPOiwqrxLDk27ZxDyPBpymbwW0z08M/odPTufRL1AVvU849Q+vBGDfD3JDyq6Z6kCPL9OzTz0rpe8FtM9vaDqXLx+W2Y7jHWJPGXT4TwJ3lW9M4bIPPCDkTwoZwE9XH1VOmksqLxLPI08cNrTvCyz4bz+Srm8kiO1vDP6nbvIpNk8MrSIvPe95zoTWR29SYsnPYC9MT2F6De93qm4PCbX9bqqhv47yky6PENE67x/DEw8JdYAvUdvcbywh6W8//ueO8fSmTyjTCi9yky6O/qr3TzvGEE8wqcTPeDmSDyuJVo8ip/ou1HqOLxOtq28y5LPuxk1Cb0Ddr+7c+2EvKQeaL1SVQk8XS47PGTcZjwdpiQ8uFqMO0QaDD1XxqS8mLmLuuSFJDz1xmy8PvgKvJAHf7yC+kE8VapuvetYC7tHCAI8oidtPOiwqjyoSW68xCo5vfzobTzz2HY88/0xPNkT4rty9om8RexLu9SiRrsVaG081gSSO5IjtTsOLpc72sTHPGCQBj0QJRI9BCclPI1sBDzCyO07QHuwvOYthTz4tGK5QHuwvWfvFz2CQNc8PviKPO8YwTuQoA89fjoMPBnBs7zGZ8m8uiPHvMdeRLx+gKE8keaku0wziDzZWfe8I4KQPJ0qpzs4sc47dyEQPEQaDDzVmcE8//uePJcIJjztTwa9ogaTOftcwztU2K48opvCuyz5drzqM1C7iYcTvfDJJjxXxiQ9o0wovO1PBrwqvGa7dSoVPbI4izvnuS88zzGrPH3POzzHXkQ9PSJqOXCUPryW4+o8ELE8PNZKp7z+Sjm8foChPPIGtzyTaUq8JA47vBiceDw3a7m6jWyEOmksKDwH59q5GMo4veALBL0SqDe7IaxvvBD3Ubxn7xc9+dkdPSBOBTxHCAI8mYvLOydCxjw5HB88zTqwvJXs77w9AZA9CxvmvIeQGL2rffm8JXkLPKqGfjyoSe464d1DPPd3UrpO/EK8qxYKvUuCojwhZlq8EPfRPKaAs7xKF9K85i0FvEYRhzyPNT88m6cBvdSiRjxnqQI9uOY2vcBFSLx4OeW7BxUbPCz59rt+W2Y7SWZsPGzUCLzE5KM7sIclvIdr3buoSW47AK0EPImHE7wgToU8IdovO7FZ5bxbzO+8uMF7PGayB7z6ioO8zzErPEcIgrxSm568FJYtvNf7jDyrffm8KaQRPcoGpTwleQu8EWKiPHPthLz44qI8pEOjvWh7QjzpPNU8lcuVPHCUPr3n/8Q8bNQIu0WmNr1Erzs95VfkPCeIW7vT0Aa7656gudH65bxw/w49ZrKHPHsn27sIUiu8mEU2vdUNF7wBf8Q809CGPFtlgDo1fcO85i2FPEcIAjwL+os653OavOu1AL2EN9K8H52fPKzoybuMdYk8T2cTO8lVPzyK5X07iNYtvD74ijzT0IY8RIF7vLLENbyZi8s8KwJ8vAne1TvGZ8k71gSSumJZwTybp4G8656gPG8IFL27SAI9arjSvKVbeDxljcy83fjSuxu4Lr2DZRK9G0TZvLFZ5bxR6ji8NPEYPbI4izyAvTE9riVaPCCUGrw0Ny48f1LhuzIb+DolBTY8UH9ou/4EpLyAvTG9CFIrvCBOBTlkIvy8WJhkvHIXZLkf47Q8GQfJvBpNXr1pcr07c8jJO2nmkrxOcJi8sy8GuzjWibu2Pta8WQO1PFPhs7z7XEO8pEMjvb9OzTz4bs08EWKiu0YyYbzeHQ695D+PPKVbeDzvGEG9B6HFO0uCojws+Xa7JQW2OpRgRbxjCqc8Sw7NPDTxmLwjXVW8sRNQvFPhszzM/Z88rVMavZPUGj06WS+8JpHgO3etursdx369uZccvKplJDws+Xa8fzGHPB1gj7yqZaQ887ecPBNZHbzoi2+7NwDpPMxDtbzfWh49H+O0PO+kaztI2kE8/xz5PImHE73fNWO8T60ovIPxPDvR2Yu8XH3VvMcYr7wfnR+9fUORPIdr3Tyn6wO9nkL8vM2uhTzGIbS66u26vE2/MrxFYKE8iwo5vLSNcLy+wiK9GTUJPK10dLzrniC8qkBpvPxTPrwzQLO8illTvFi9H7yMATS7ayOjO14Ae7z19Cy87dswPKbGyDzujJa93EdtPdsB2LYT5Ue9RhEHPKurubxm+By9+mVIvIy7HrxZj987yOpuvUdv8TvgCwS8TDMIO9xsqLsL+gs8BWS1PFRMBD1yXXm86GoVvK+QqjxRXg46TZHyu2ayhzx7TJa8uKAhPLyFkjsV3MI7niGiPGNQvDxgkIa887ccPUmLJ7yZsIa8KDnBvHgYi7yMR0m82ukCvRuK7junUvO8aeYSPXtt8LqXCKa84kgUPd5jIzxlRze93xQJPNNcMT2v1j889GiCPKRkfbxz7YQ8b06pO8cYL7xg9/U8yQ+qPGlyvbzfNWO8vZ3nPBGD/DtB5gC7yKRZPPTPcbz6q928bleuPI74rrzVDRe9CQORvMmb1Dzv0qs8DBLhu4dr3bta1fQ8aeYSvRD3UTugpMe8CxvmPP9BNDzHjAQ742DpOzXD2Dz4bk28c1T0Onxka7zEBf48uiNHvGayBz1pcj29NcPYvDnu3jz5kwg9WkBFvL58jTx/mHY8wTzDPDZ0Pru/uZ08PQGQPOFRmby4oKE8JktLPIx1iTsppBG9dyGQvHfzT7wzhki44KAzPSOCkDzv0iu8lGBFO2VHNzyKxKM72EEiPYtQzryT9fQ8UDnTPEx5nTzuZ9s8QO8FvG8IlDx7J9s6MUk4O9k4nbx7TBa7G7iuvCzYHDocr6k8/7UJPY2ymTwVIlg8KjC8OvSuFz2iJ+28cCBpvE0qAzw41ok7sgrLvPjiojyG37K6lwimvKcxGTwRHI28y5LPO/mTiDx82MC5VJIZPWkH7TwPusG8YhOsvH1DkbzUx4E8TQXIvO+ka7zKwI+8w+2oPNLxYLzxegy9zEM1PDo0dDxIINc8FdxCO46E2TwPRmw9+ooDvMmb1LwBf0S8CQMRvEXsS7zPvdU80qvLPLfvO7wbuK68iBzDO0cpXL2WndU7dXCqvOTLubytLl88LokCvZj/IDw0q4M8G7guvNkTYrq5UQe7vcunvIrEI7xuERm9RexLvAdbsDwLQCE7uVEHPYjWrbuM3Pi8g2WSO3R5L7x4XiC8vKZsu9Sixros+fa8UH/ouxxpFL3wyaa72sRHu2YZ9zuiJ2274o4pOjkcnzyagka7za4FvYrEozwCMCo7cJQ+vfqKAzzJ4em8fNhAPUB7sLylz80833v4vOU2ir1ty4M8UV4OPXQF2jyu30S9EjRivBVo7TwXX2g70ANrvEJyq7wQJRK99jE9O7c10brUxwE9SUUSPS4VLbzBsJg7FHHyPMz9n7latJo8bleuvBpN3jsF+WS8Ye7wO4nNKL0TWZ08iRM+vOn2v7sB8xm9jY3ePJ/zYbkLG+a7ZvicvGxgM73L2OS761iLPKcxmTrX+ww8J0JGu1MnyTtJZuw7pIm4PJbCED29V1K9PFCqPLBBkLxhYka8hXTiPEB7MDzrniA7h5CYvIR9ZzzARcg7TZHyu4sKOb1in9Y7nL9WO6gD2TxSduO8UaQjPQO81Lxw/w69KwL8O4FJ3D2XTju8SE6XPGDWGz0K1VC8YhMsvObCtDyndy49BCclu68cVbxemYu8sGLqOksOzTzj1L47ISBFvLly4Ttk3Oa8RhGHNwzxBj0v5+y7ogaTPA+6QbxiE6w8ubj2PDixzrstZEe9jbKZPPd30rwqMDw8TQXIPFurlTxx0c68jLsePfSJ3LuXTru8yeHpu6Ewcjx5D4a8BvBfvN8Uibs9R6W8lsIQvaEw8rvVUyw8SJQsPebCNDwu8PE8GMo4OxAlkjwJmMA8KaQRvdYlbDwNNxy9ouHXPDffDrxwZv46AK0EPJqCRrpWz6k8/0E0POAs3rxmsoe7zTqwO5mLyzyP7ym7wTzDvFB/aLx5D4a7doj/O67fxDtsO/g7uq9xvMWViTtC/tU7PhnlvIEogjxxRSQ9SJSsPIJA1zyBKAI9ockCPYC9MbxBTXC83xSJvPFVUb1n75c8uiNHOxdf6Drt27A8/FM+vJOvXz3a6QI8UaQjuvqKgzyOhNm831oevF+xYLxjCic8sn6gPDdrOTs3Rv66cP+Ou5785rycBew8J0JGPJOOBbw9Imq8q335O3MOX7xemQs8PtNPPE1L3Tx5dnU4A+EPPLrdsTzfFIm7LJIHPB4yz7zbAdi8FWjtu1h3Cj0oznA8kv55PKgDWbxIINc8xdsePa8cVbzmlHQ8IJSavAgMlrx4XiA8z3dAu2PEET3xm+a75//EvK2Zr7xbqxU8zP2fvOSFJD1xRSS7k44FvPzHkzz5+ne8+tAYvd5jIz1GMuE8yxSAO3KCNDyRuOS8wzO+vObCNDwzQLO7isQjva1TGrz6ioM79GgCPF66Zbx1KpW8qW6pu4RcDTzcJhO9SJQsO5G45LsAiMm8lRErvJqCxjzQbju7w3nTuTclpDywqP88ysCPvAF/xLxfa0u88cChPBjKODyaPLE8k69fvGFiRrvuRgG9ATmvvJEsOr21+EC9KX/WOrmXnDwDAuo8yky6PI1sBDvztxy8PviKPKInbbzbdS276mGQO2Kf1rwn/DC8ZrIHPBRxcj0z+h264d1DPdG0ULxvTqm5bDt4vToTmjuGJcg7tmMRO9YEEr3oJAC9THmdPKn607vcJhM8Zj6yvHR5r7ywYmq83fjSO5mLyzshIEU8EWKiuu9eVjw75dk7fzGHvNl+sjwJJOs8YllBPAtheztz7QQ92lDyvDEDozzEKrk7KnZRvG8pbjsdYI+7yky6OfWAVzzjYGk7NX3DOzrNhDyeIaI8joTZvFcMOryYRba8G7iuu893QDw9RyW7za6FvDUJ7rva6YK9D7rBPD1o/zxCLJa65TaKvHsGAT2g6ly8+tCYu+wqy7xeAHu8vZ1nPBv+QzwfVwo8CMYAvM+91TzKTDq8Ueo4u2uvzTsBf8Q8p+uDvKofDz12tj+8wP+yOlkDtTwYyji6ZdPhPGv14rwqdtE8YPf1vLIKy7yFLs28ouFXvO1PBj15pDU83xQJPdfWUTz8x5O64kgUPBQKA72eIaK6A3a/OyzYnLoYnPg4XMNqPdxsqLsKSaY7pfSIvBoshLupKJS8G0TZOu/SqzzFcE47cvaJPA19Mb14dQC8sVllvJmwhjycBey8cvaJOmSWUbvRtFC8WtX0O2r+57twIGm8yeFpvFuG2rzCyO08PUelPK5rbzouFS29uCxMPQAUdDqtma88wqeTu5gge7zH8/O7l067PJdOO7uKxCO8/xx5vKt9+TztTwa8OhOaO+Q/Dzw33w49CZhAvSubjDydttG8IdovPIADR7stHrI7ATmvvOAs3rzL2OQ69K4XvNccZ7zlV2S8c+0EPfNDxzydKqc6LLPhO8YhtDyJhxM9H1eKOaNMKLtOcBg9HPU+PTsrbzvT0Ia8BG26PB2mpDp7TJa8wP8yPVvM77t0ea86eTBgvFurFT1C/tW7CkkmvKOSPT2aPDG9lGDFPAhSq7u5UYc8l5TQPFh3ijz9vg68lGBFO4/vKTxViZS7eQ8GPTNAs7xmsoe8o0yoPJfaZbwlvyA8IazvO0XsS717TJY8flvmOgHFWbyWnVW8mdFgvJbCkDynDF68" + } + ], + "model": "text-embedding-3-small", + "usage": { + "prompt_tokens": 9, + "total_tokens": 9 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IEmbeddingGenerator> generator = new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + }).AsEmbeddingGenerator("text-embedding-3-small"); + + 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("text-embedding-3-small", e.ModelId); + Assert.NotNull(e.CreatedAt); + Assert.Equal(1536, e.Vector.Length); + Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs new file mode 100644 index 00000000000..ba1c85d700a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ChatClientBuilderTest +{ + [Fact] + public void PassesServiceProviderToFactories() + { + var expectedServiceProvider = new ServiceCollection().BuildServiceProvider(); + using TestChatClient expectedResult = new(); + var builder = new ChatClientBuilder(expectedServiceProvider); + + builder.Use((serviceProvider, innerClient) => + { + Assert.Same(expectedServiceProvider, serviceProvider); + return expectedResult; + }); + + using TestChatClient innerClient = new(); + Assert.Equal(expectedResult, builder.Use(innerClient: innerClient)); + } + + [Fact] + public void BuildsPipelineInOrderAdded() + { + // Arrange + using TestChatClient expectedInnerClient = new(); + var builder = new ChatClientBuilder(); + + builder.Use(next => new InnerClientCapturingChatClient("First", next)); + builder.Use(next => new InnerClientCapturingChatClient("Second", next)); + builder.Use(next => new InnerClientCapturingChatClient("Third", next)); + + // Act + var first = (InnerClientCapturingChatClient)builder.Use(expectedInnerClient); + + // Assert + Assert.Equal("First", first.Name); + var second = (InnerClientCapturingChatClient)first.InnerClient; + Assert.Equal("Second", second.Name); + var third = (InnerClientCapturingChatClient)second.InnerClient; + Assert.Equal("Third", third.Name); + Assert.Same(expectedInnerClient, third.InnerClient); + } + + [Fact] + public void DoesNotAcceptNullInnerService() + { + Assert.Throws(() => new ChatClientBuilder().Use((IChatClient)null!)); + } + + [Fact] + public void DoesNotAcceptNullFactories() + { + ChatClientBuilder builder = new(); + Assert.Throws(() => builder.Use((Func)null!)); + Assert.Throws(() => builder.Use((Func)null!)); + } + + [Fact] + public void DoesNotAllowFactoriesToReturnNull() + { + ChatClientBuilder builder = new(); + builder.Use(_ => null!); + var ex = Assert.Throws(() => builder.Use(new TestChatClient())); + Assert.Contains("entry at index 0", ex.Message); + } + + private sealed class InnerClientCapturingChatClient(string name, IChatClient innerClient) : DelegatingChatClient(innerClient) + { +#pragma warning disable S3604 // False positive: Member initializer values should not be redundant + public string Name { get; } = name; +#pragma warning restore S3604 + public new IChatClient InnerClient => base.InnerClient; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs new file mode 100644 index 00000000000..0e776b4fee5 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -0,0 +1,256 @@ +// 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.Text.Json; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ChatClientStructuredOutputExtensionsTests +{ + [Fact] + public async Task SuccessUsage() + { + var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))]) + { + CompletionId = "test", + CreatedAt = DateTimeOffset.UtcNow, + ModelId = "someModel", + RawRepresentation = new object(), + Usage = new(), + }; + + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => + { + var responseFormat = Assert.IsType(options!.ResponseFormat); + Assert.Null(responseFormat.Schema); + Assert.Null(responseFormat.SchemaName); + Assert.Null(responseFormat.SchemaDescription); + + // The inner client receives a trailing "system" message with the schema instruction + Assert.Collection(messages, + message => Assert.Equal("Hello", message.Text), + message => + { + Assert.Equal(ChatRole.System, message.Role); + Assert.Contains("Respond with a JSON value", message.Text); + Assert.Contains("https://json-schema.org/draft/2020-12/schema", message.Text); + foreach (Species v in Enum.GetValues(typeof(Species))) + { + Assert.Contains(v.ToString(), message.Text); // All enum values are described as strings + } + }); + + return Task.FromResult(expectedCompletion); + }, + }; + + var chatHistory = new List { new(ChatRole.User, "Hello") }; + var response = await client.CompleteAsync(chatHistory); + + // The completion contains the deserialized result and other completion properties + Assert.Equal(1, response.Result.Id); + Assert.Equal("Tigger", response.Result.FullName); + Assert.Equal(Species.Tiger, response.Result.Species); + Assert.Equal(expectedCompletion.CompletionId, response.CompletionId); + Assert.Equal(expectedCompletion.CreatedAt, response.CreatedAt); + Assert.Equal(expectedCompletion.ModelId, response.ModelId); + Assert.Same(expectedCompletion.RawRepresentation, response.RawRepresentation); + Assert.Same(expectedCompletion.Usage, response.Usage); + + // TryGetResult returns the same value + Assert.True(response.TryGetResult(out var tryGetResultOutput)); + Assert.Same(response.Result, tryGetResultOutput); + + // Doesn't mutate history (or at least, reverts any changes) + Assert.Equal("Hello", Assert.Single(chatHistory).Text); + } + + [Fact] + public async Task FailureUsage_InvalidJson() + { + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, "This is not valid JSON")]); + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => Task.FromResult(expectedCompletion), + }; + + var chatHistory = new List { new(ChatRole.User, "Hello") }; + var response = await client.CompleteAsync(chatHistory); + + var ex = Assert.Throws(() => response.Result); + Assert.Contains("invalid", ex.Message); + + Assert.False(response.TryGetResult(out var tryGetResult)); + Assert.Null(tryGetResult); + } + + [Fact] + public async Task FailureUsage_NullJson() + { + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, "null")]); + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => Task.FromResult(expectedCompletion), + }; + + var chatHistory = new List { new(ChatRole.User, "Hello") }; + var response = await client.CompleteAsync(chatHistory); + + var ex = Assert.Throws(() => response.Result); + Assert.Equal("The deserialized response is null", ex.Message); + + Assert.False(response.TryGetResult(out var tryGetResult)); + Assert.Null(tryGetResult); + } + + [Fact] + public async Task FailureUsage_NoJsonInResponse() + { + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, [new ImageContent("https://example.com")])]); + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => Task.FromResult(expectedCompletion), + }; + + var chatHistory = new List { new(ChatRole.User, "Hello") }; + var response = await client.CompleteAsync(chatHistory); + + var ex = Assert.Throws(() => response.Result); + Assert.Equal("The response did not contain text to be deserialized", ex.Message); + + Assert.False(response.TryGetResult(out var tryGetResult)); + Assert.Null(tryGetResult); + } + + [Fact] + public async Task CanUseNativeStructuredOutput() + { + var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))]); + + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => + { + var responseFormat = Assert.IsType(options!.ResponseFormat); + Assert.Equal(nameof(Animal), responseFormat.SchemaName); + Assert.Equal("Some test description", responseFormat.SchemaDescription); + Assert.Contains("https://json-schema.org/draft/2020-12/schema", responseFormat.Schema); + foreach (Species v in Enum.GetValues(typeof(Species))) + { + Assert.Contains(v.ToString(), responseFormat.Schema); // All enum values are described as strings + } + + // The chat history isn't mutated any further, since native structured output is used instead of a prompt + Assert.Equal("Hello", Assert.Single(messages).Text); + + return Task.FromResult(expectedCompletion); + }, + }; + + var chatHistory = new List { new(ChatRole.User, "Hello") }; + var response = await client.CompleteAsync(chatHistory, useNativeJsonSchema: true); + + // The completion contains the deserialized result and other completion properties + Assert.Equal(1, response.Result.Id); + Assert.Equal("Tigger", response.Result.FullName); + Assert.Equal(Species.Tiger, response.Result.Species); + + // TryGetResult returns the same value + Assert.True(response.TryGetResult(out var tryGetResultOutput)); + Assert.Same(response.Result, tryGetResultOutput); + + // History remains unmutated + Assert.Equal("Hello", Assert.Single(chatHistory).Text); + } + + [Fact] + public async Task CanSpecifyCustomJsonSerializationOptions() + { + var jso = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, jso))]); + + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Collection(messages, + message => Assert.Equal("Hello", message.Text), + message => + { + Assert.Equal(ChatRole.System, message.Role); + Assert.Contains("Respond with a JSON value", message.Text); + Assert.Contains("https://json-schema.org/draft/2020-12/schema", message.Text); + Assert.DoesNotContain(nameof(Animal.FullName), message.Text); // The JSO uses snake_case + Assert.Contains("full_name", message.Text); // The JSO uses snake_case + Assert.DoesNotContain(nameof(Species.Tiger), message.Text); // The JSO doesn't use enum-to-string conversion + }); + + return Task.FromResult(expectedCompletion); + }, + }; + + var chatHistory = new List { new(ChatRole.User, "Hello") }; + var response = await client.CompleteAsync(chatHistory, jso); + + // The completion contains the deserialized result and other completion properties + Assert.Equal(1, response.Result.Id); + Assert.Equal("Tigger", response.Result.FullName); + Assert.Equal(Species.Tiger, response.Result.Species); + } + + [Fact] + public async Task HandlesBackendReturningMultipleObjects() + { + // A very common failure mode for GPT 3.5 Turbo is that instead of returning a single top-level JSON object, + // it may return multiple, particularly when function calling is involved. + // See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348 + // Fortunately we can work around this without breaking any cases of valid output. + + var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; + var resultDuplicatedJson = JsonSerializer.Serialize(expectedResult) + Environment.NewLine + JsonSerializer.Serialize(expectedResult); + + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => + { + return Task.FromResult(new ChatCompletion([new ChatMessage(ChatRole.Assistant, resultDuplicatedJson)])); + }, + }; + + var chatHistory = new List { new(ChatRole.User, "Hello") }; + var response = await client.CompleteAsync(chatHistory); + + // The completion contains the deserialized result and other completion properties + Assert.Equal(1, response.Result.Id); + Assert.Equal("Tigger", response.Result.FullName); + Assert.Equal(Species.Tiger, response.Result.Species); + } + + [Description("Some test description")] + private class Animal + { + public int Id { get; set; } + public string? FullName { get; set; } + public Species Species { get; set; } + } + + private enum Species + { + Bear, + Tiger, + Walrus, + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs new file mode 100644 index 00000000000..a27761c99ec --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs @@ -0,0 +1,85 @@ +// 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 ConfigureOptionsChatClientTests +{ + [Fact] + public void ConfigureOptionsChatClient_InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new ConfigureOptionsChatClient(null!, _ => new ChatOptions())); + Assert.Throws("configureOptions", () => new ConfigureOptionsChatClient(new TestChatClient(), null!)); + } + + [Fact] + public void UseChatOptions_InvalidArgs_Throws() + { + var builder = new ChatClientBuilder(); + Assert.Throws("configureOptions", () => builder.UseChatOptions(null!)); + } + + [Fact] + public async Task ConfigureOptions_ReturnedInstancePassedToNextClient() + { + ChatOptions providedOptions = new(); + ChatOptions returnedOptions = new(); + ChatCompletion expectedCompletion = new(Array.Empty()); + var expectedUpdates = Enumerable.Range(0, 3).Select(i => new StreamingChatCompletionUpdate()).ToArray(); + using CancellationTokenSource cts = new(); + + using IChatClient innerClient = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Same(returnedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return Task.FromResult(expectedCompletion); + }, + + CompleteStreamingAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Same(returnedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return YieldUpdates(expectedUpdates); + }, + }; + + using var client = new ChatClientBuilder() + .UseChatOptions(options => + { + Assert.Same(providedOptions, options); + return returnedOptions; + }) + .Use(innerClient); + + var completion = await client.CompleteAsync(Array.Empty(), providedOptions, cts.Token); + Assert.Same(expectedCompletion, completion); + + int i = 0; + await using var e = client.CompleteStreamingAsync(Array.Empty(), providedOptions, cts.Token).GetAsyncEnumerator(); + while (i < expectedUpdates.Length) + { + Assert.True(await e.MoveNextAsync()); + Assert.Same(expectedUpdates[i++], e.Current); + } + + Assert.False(await e.MoveNextAsync()); + + static async IAsyncEnumerable YieldUpdates(StreamingChatCompletionUpdate[] updates) + { + foreach (var update in updates) + { + await Task.Yield(); + yield return update; + } + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/CustomAIContentJsonContext.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/CustomAIContentJsonContext.cs new file mode 100644 index 00000000000..650a8fdd162 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/CustomAIContentJsonContext.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. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +[JsonSerializable(typeof(DistributedCachingChatClientTest.CustomAIContent1))] +[JsonSerializable(typeof(DistributedCachingChatClientTest.CustomAIContent2))] +internal sealed partial class CustomAIContentJsonContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs new file mode 100644 index 00000000000..9bbfbea98c3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DependencyInjectionPatterns +{ + private IServiceCollection ServiceCollection { get; } = new ServiceCollection(); + + [Fact] + public void CanRegisterScopedUsingGenericType() + { + // Arrange/Act + ServiceCollection.AddChatClient(builder => builder + .UseScopedMiddleware() + .Use(new TestChatClient())); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance1Copy = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets a distinct outer *AND* inner client + var outer1 = Assert.IsType(instance1); + var outer2 = Assert.IsType(instance2); + var inner1 = Assert.IsType(((ScopedChatClient)instance1).InnerClient); + var inner2 = Assert.IsType(((ScopedChatClient)instance2).InnerClient); + + Assert.NotSame(outer1.Services, outer2.Services); + Assert.NotSame(instance1, instance2); + Assert.NotSame(inner1, inner2); + Assert.Same(instance1, instance1Copy); // From the same scope + } + + [Fact] + public void CanRegisterScopedUsingFactory() + { + // Arrange/Act + ServiceCollection.AddChatClient(builder => + { + builder.UseScopedMiddleware(); + return builder.Use(new TestChatClient { Services = builder.Services }); + }); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets a distinct outer *AND* inner client + var outer1 = Assert.IsType(instance1); + var outer2 = Assert.IsType(instance2); + var inner1 = Assert.IsType(((ScopedChatClient)instance1).InnerClient); + var inner2 = Assert.IsType(((ScopedChatClient)instance2).InnerClient); + + Assert.Same(outer1.Services, inner1.Services); + Assert.Same(outer2.Services, inner2.Services); + Assert.NotSame(outer1.Services, outer2.Services); + } + + [Fact] + public void CanRegisterScopedUsingSharedInstance() + { + // Arrange/Act + using var singleton = new TestChatClient(); + ServiceCollection.AddChatClient(builder => + { + builder.UseScopedMiddleware(); + return builder.Use(singleton); + }); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets a distinct outer instance, but the same inner client + Assert.IsType(instance1); + Assert.IsType(instance2); + Assert.Same(singleton, ((ScopedChatClient)instance1).InnerClient); + Assert.Same(singleton, ((ScopedChatClient)instance2).InnerClient); + } + + public class ScopedChatClient(IServiceProvider services, IChatClient inner) : DelegatingChatClient(inner) + { + public new IChatClient InnerClient => base.InnerClient; + public IServiceProvider Services => services; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs new file mode 100644 index 00000000000..35ced372eb2 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs @@ -0,0 +1,703 @@ +// 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.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DistributedCachingChatClientTest +{ + private readonly TestInMemoryCacheStorage _storage = new(); + + [Fact] + public async Task CachesSuccessResultsAsync() + { + // Arrange + + // Verify that all the expected properties will round-trip through the cache, + // even if this involves serialization + var expectedCompletion = new ChatCompletion([ + new(new ChatRole("fakeRole"), "This is some content") + { + AdditionalProperties = new() { ["a"] = "b" }, + Contents = [new FunctionCallContent("someCallId", "functionName", new Dictionary + { + ["arg1"] = "value1", + ["arg2"] = 123, + ["arg3"] = 123.4, + ["arg4"] = true, + ["arg5"] = false, + ["arg6"] = null + })] + } + ]) + { + CompletionId = "someId", + Usage = new() + { + InputTokenCount = 123, + OutputTokenCount = 456, + TotalTokenCount = 99999, + }, + CreatedAt = DateTimeOffset.UtcNow, + ModelId = "someModel", + AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 123 } + }; + + var innerCallCount = 0; + using var testClient = new TestChatClient + { + CompleteAsyncCallback = delegate + { + innerCallCount++; + return Task.FromResult(expectedCompletion); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + // Make the initial request and do a quick sanity check + var result1 = await outer.CompleteAsync([new ChatMessage(ChatRole.User, "some input")]); + Assert.Same(expectedCompletion, result1); + Assert.Equal(1, innerCallCount); + + // Act + var result2 = await outer.CompleteAsync([new ChatMessage(ChatRole.User, "some input")]); + + // Assert + Assert.Equal(1, innerCallCount); + AssertCompletionsEqual(expectedCompletion, result2); + + // Act/Assert 2: Cache misses do not return cached results + await outer.CompleteAsync([new ChatMessage(ChatRole.User, "some modified input")]); + Assert.Equal(2, innerCallCount); + } + + [Fact] + public async Task AllowsConcurrentCallsAsync() + { + // Arrange + var innerCallCount = 0; + var completionTcs = new TaskCompletionSource(); + using var testClient = new TestChatClient + { + CompleteAsyncCallback = async delegate + { + innerCallCount++; + await completionTcs.Task; + return new ChatCompletion([new(ChatRole.Assistant, "Hello")]); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + // Act 1: Concurrent calls before resolution are passed into the inner client + var result1 = outer.CompleteAsync([new ChatMessage(ChatRole.User, "some input")]); + var result2 = outer.CompleteAsync([new ChatMessage(ChatRole.User, "some input")]); + + // Assert 1 + Assert.Equal(2, innerCallCount); + Assert.False(result1.IsCompleted); + Assert.False(result2.IsCompleted); + completionTcs.SetResult(true); + Assert.Equal("Hello", (await result1).Message.Text); + Assert.Equal("Hello", (await result2).Message.Text); + + // Act 2: Subsequent calls after completion are resolved from the cache + var result3 = outer.CompleteAsync([new ChatMessage(ChatRole.User, "some input")]); + Assert.Equal(2, innerCallCount); + Assert.Equal("Hello", (await result3).Message.Text); + } + + [Fact] + public async Task DoesNotCacheExceptionResultsAsync() + { + // Arrange + var innerCallCount = 0; + using var testClient = new TestChatClient + { + CompleteAsyncCallback = delegate + { + innerCallCount++; + throw new InvalidTimeZoneException("some failure"); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + var input = new ChatMessage(ChatRole.User, "abc"); + var ex1 = await Assert.ThrowsAsync(() => outer.CompleteAsync([input])); + Assert.Equal("some failure", ex1.Message); + Assert.Equal(1, innerCallCount); + + // Act + var ex2 = await Assert.ThrowsAsync(() => outer.CompleteAsync([input])); + + // Assert + Assert.NotSame(ex1, ex2); + Assert.Equal("some failure", ex2.Message); + Assert.Equal(2, innerCallCount); + } + + [Fact] + public async Task DoesNotCacheCanceledResultsAsync() + { + // Arrange + var innerCallCount = 0; + var resolutionTcs = new TaskCompletionSource(); + using var testClient = new TestChatClient + { + CompleteAsyncCallback = async delegate + { + innerCallCount++; + if (innerCallCount == 1) + { + await resolutionTcs.Task; + } + + return new ChatCompletion([new(ChatRole.Assistant, "A good result")]); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + // First call gets cancelled + var input = new ChatMessage(ChatRole.User, "abc"); + var result1 = outer.CompleteAsync([input]); + Assert.False(result1.IsCompleted); + Assert.Equal(1, innerCallCount); + resolutionTcs.SetCanceled(); + await Assert.ThrowsAsync(() => result1); + Assert.True(result1.IsCanceled); + + // Act/Assert: Second call can succeed + var result2 = await outer.CompleteAsync([input]); + Assert.Equal(2, innerCallCount); + Assert.Equal("A good result", result2.Message.Text); + } + + [Fact] + public async Task StreamingCachesSuccessResultsAsync() + { + // Arrange + + // Verify that all the expected properties will round-trip through the cache, + // even if this involves serialization + List expectedCompletion = + [ + new() + { + Role = new ChatRole("fakeRole1"), + ChoiceIndex = 3, + AdditionalProperties = new() { ["a"] = "b" }, + Contents = [new TextContent("Chunk1")] + }, + new() + { + Role = new ChatRole("fakeRole2"), + Text = "Chunk2", + Contents = + [ + new FunctionCallContent("someCallId", "someFn", new Dictionary { ["arg1"] = "value1" }), + new UsageContent(new() { InputTokenCount = 123, OutputTokenCount = 456, TotalTokenCount = 99999 }), + ] + } + ]; + + var innerCallCount = 0; + using var testClient = new TestChatClient + { + CompleteStreamingAsyncCallback = delegate + { + innerCallCount++; + return ToAsyncEnumerableAsync(expectedCompletion); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + // Make the initial request and do a quick sanity check + var result1 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); + await AssertCompletionsEqualAsync(expectedCompletion, result1); + Assert.Equal(1, innerCallCount); + + // Act + var result2 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); + + // Assert + Assert.Equal(1, innerCallCount); + await AssertCompletionsEqualAsync(expectedCompletion, result2); + + // Act/Assert 2: Cache misses do not return cached results + await ToListAsync(outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some modified input")])); + Assert.Equal(2, innerCallCount); + } + + [Fact] + public async Task StreamingCoalescesConsecutiveTextChunksAsync() + { + // Arrange + List expectedCompletion = + [ + new() { Role = ChatRole.Assistant, Text = "This" }, + new() { Role = ChatRole.Assistant, Text = " becomes one chunk" }, + new() { Role = ChatRole.Assistant, Contents = [new FunctionCallContent("callId1", "separator")] }, + new() { Role = ChatRole.Assistant, Text = "... and this" }, + new() { Role = ChatRole.Assistant, Text = " becomes another" }, + new() { Role = ChatRole.Assistant, Text = " one." }, + ]; + + using var testClient = new TestChatClient + { + CompleteStreamingAsyncCallback = delegate { return ToAsyncEnumerableAsync(expectedCompletion); } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + var result1 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); + await ToListAsync(result1); + + // Act + var result2 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); + + // Assert + Assert.Collection(await ToListAsync(result2), + c => Assert.Equal("This becomes one chunk", c.Text), + c => Assert.IsType(Assert.Single(c.Contents)), + c => Assert.Equal("... and this becomes another one.", c.Text)); + } + + [Fact] + public async Task StreamingAllowsConcurrentCallsAsync() + { + // Arrange + var innerCallCount = 0; + var completionTcs = new TaskCompletionSource(); + List expectedCompletion = + [ + new() { Role = ChatRole.Assistant, Text = "Chunk 1" }, + new() { Role = ChatRole.System, Text = "Chunk 2" }, + ]; + using var testClient = new TestChatClient + { + CompleteStreamingAsyncCallback = delegate + { + innerCallCount++; + return ToAsyncEnumerableAsync(completionTcs.Task, expectedCompletion); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + // Act 1: Concurrent calls before resolution are passed into the inner client + var result1 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); + var result2 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); + + // Assert 1 + Assert.NotSame(result1, result2); + var result1Assertion = AssertCompletionsEqualAsync(expectedCompletion, result1); + var result2Assertion = AssertCompletionsEqualAsync(expectedCompletion, result2); + Assert.False(result1Assertion.IsCompleted); + Assert.False(result2Assertion.IsCompleted); + completionTcs.SetResult(true); + await result1Assertion; + await result2Assertion; + Assert.Equal(2, innerCallCount); + + // Act 2: Subsequent calls after completion are resolved from the cache + var result3 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); + await AssertCompletionsEqualAsync(expectedCompletion, result3); + Assert.Equal(2, innerCallCount); + } + + [Fact] + public async Task StreamingDoesNotCacheExceptionResultsAsync() + { + // Arrange + var innerCallCount = 0; + using var testClient = new TestChatClient + { + CompleteStreamingAsyncCallback = delegate + { + innerCallCount++; + return ToAsyncEnumerableAsync(Task.CompletedTask, + [ + () => new() { Role = ChatRole.Assistant, Text = "Chunk 1" }, + () => throw new InvalidTimeZoneException("some failure"), + ]); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + var input = new ChatMessage(ChatRole.User, "abc"); + var result1 = outer.CompleteStreamingAsync([input]); + var ex1 = await Assert.ThrowsAsync(() => ToListAsync(result1)); + Assert.Equal("some failure", ex1.Message); + Assert.Equal(1, innerCallCount); + + // Act + var result2 = outer.CompleteStreamingAsync([input]); + var ex2 = await Assert.ThrowsAsync(() => ToListAsync(result2)); + + // Assert + Assert.NotSame(ex1, ex2); + Assert.Equal("some failure", ex2.Message); + Assert.Equal(2, innerCallCount); + } + + [Fact] + public async Task StreamingDoesNotCacheCanceledResultsAsync() + { + // Arrange + var innerCallCount = 0; + var completionTcs = new TaskCompletionSource(); + using var testClient = new TestChatClient + { + CompleteStreamingAsyncCallback = delegate + { + innerCallCount++; + return ToAsyncEnumerableAsync( + innerCallCount == 1 ? completionTcs.Task : Task.CompletedTask, + [() => new() { Role = ChatRole.Assistant, Text = "A good result" }]); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + // First call gets cancelled + var input = new ChatMessage(ChatRole.User, "abc"); + var result1 = outer.CompleteStreamingAsync([input]); + var result1Assertion = ToListAsync(result1); + Assert.False(result1Assertion.IsCompleted); + completionTcs.SetCanceled(); + await Assert.ThrowsAsync(() => result1Assertion); + Assert.True(result1Assertion.IsCanceled); + Assert.Equal(1, innerCallCount); + + // Act/Assert: Second call can succeed + var result2 = await ToListAsync(outer.CompleteStreamingAsync([input])); + Assert.Equal("A good result", result2[0].Text); + Assert.Equal(2, innerCallCount); + } + + [Fact] + public async Task CacheKeyDoesNotVaryByChatOptionsAsync() + { + // Arrange + var innerCallCount = 0; + var completionTcs = new TaskCompletionSource(); + using var testClient = new TestChatClient + { + CompleteAsyncCallback = async (_, options, _) => + { + innerCallCount++; + await Task.Yield(); + return new([new(ChatRole.Assistant, options!.AdditionalProperties!["someKey"]!.ToString())]); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + // Act: Call with two different ChatOptions + var result1 = await outer.CompleteAsync([], new ChatOptions + { + AdditionalProperties = new() { { "someKey", "value 1" } } + }); + var result2 = await outer.CompleteAsync([], new ChatOptions + { + AdditionalProperties = new() { { "someKey", "value 2" } } + }); + + // Assert: Same result + Assert.Equal(1, innerCallCount); + Assert.Equal("value 1", result1.Message.Text); + Assert.Equal("value 1", result2.Message.Text); + } + + [Fact] + public async Task SubclassCanOverrideCacheKeyToVaryByChatOptionsAsync() + { + // Arrange + var innerCallCount = 0; + var completionTcs = new TaskCompletionSource(); + using var testClient = new TestChatClient + { + CompleteAsyncCallback = async (_, options, _) => + { + innerCallCount++; + await Task.Yield(); + return new([new(ChatRole.Assistant, options!.AdditionalProperties!["someKey"]!.ToString())]); + } + }; + using var outer = new CachingChatClientWithCustomKey(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + // Act: Call with two different ChatOptions + var result1 = await outer.CompleteAsync([], new ChatOptions + { + AdditionalProperties = new() { { "someKey", "value 1" } } + }); + var result2 = await outer.CompleteAsync([], new ChatOptions + { + AdditionalProperties = new() { { "someKey", "value 2" } } + }); + + // Assert: Different results + Assert.Equal(2, innerCallCount); + Assert.Equal("value 1", result1.Message.Text); + Assert.Equal("value 2", result2.Message.Text); + } + + [Fact] + public async Task CanCacheCustomContentTypesAsync() + { + // Arrange + var expectedCompletion = new ChatCompletion([ + new(new ChatRole("fakeRole"), + [ + new CustomAIContent1("Hello", DateTime.Now), + new CustomAIContent2("Goodbye", 42), + ]) + ]); + + var serializerOptions = new JsonSerializerOptions(TestJsonSerializerContext.Default.Options); + serializerOptions.TypeInfoResolver = serializerOptions.TypeInfoResolver!.WithAddedModifier(typeInfo => + { + if (typeInfo.Type == typeof(AIContent)) + { + foreach (var t in new Type[] { typeof(CustomAIContent1), typeof(CustomAIContent2) }) + { + typeInfo.PolymorphismOptions!.DerivedTypes.Add(new JsonDerivedType(t, t.Name)); + } + } + }); + serializerOptions.TypeInfoResolverChain.Add(CustomAIContentJsonContext.Default); + + var innerCallCount = 0; + using var testClient = new TestChatClient + { + CompleteAsyncCallback = delegate + { + innerCallCount++; + return Task.FromResult(expectedCompletion); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = serializerOptions + }; + + // Make the initial request and do a quick sanity check + var result1 = await outer.CompleteAsync([new ChatMessage(ChatRole.User, "some input")]); + AssertCompletionsEqual(expectedCompletion, result1); + + // Act + var result2 = await outer.CompleteAsync([new ChatMessage(ChatRole.User, "some input")]); + + // Assert + Assert.Equal(1, innerCallCount); + AssertCompletionsEqual(expectedCompletion, result2); + Assert.NotSame(result2.Message.Contents[0], expectedCompletion.Message.Contents[0]); + Assert.NotSame(result2.Message.Contents[1], expectedCompletion.Message.Contents[1]); + } + + [Fact] + public async Task CanResolveIDistributedCacheFromDI() + { + // Arrange + var services = new ServiceCollection() + .AddSingleton(_storage) + .BuildServiceProvider(); + using var testClient = new TestChatClient + { + CompleteAsyncCallback = delegate + { + return Task.FromResult(new ChatCompletion([ + new(ChatRole.Assistant, [new TextContent("Hey")])])); + } + }; + using var outer = new ChatClientBuilder(services) + .UseDistributedCache(configure: options => + { + options.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; + }) + .Use(testClient); + + // Act: Make a request that should populate the cache + Assert.Empty(_storage.Keys); + var result = await outer.CompleteAsync([new ChatMessage(ChatRole.User, "some input")]); + + // Assert + Assert.NotNull(result); + Assert.Single(_storage.Keys); + } + + private static async Task> ToListAsync(IAsyncEnumerable values) + { + var result = new List(); + await foreach (var v in values) + { + result.Add(v); + } + + return result; + } + + private static IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable values) + => ToAsyncEnumerableAsync(Task.CompletedTask, values); + + private static IAsyncEnumerable ToAsyncEnumerableAsync(Task preTask, IEnumerable valueFactories) + => ToAsyncEnumerableAsync(preTask, valueFactories.Select>(v => () => v)); + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(Task preTask, IEnumerable> values) + { + await preTask; + + foreach (var value in values) + { + await Task.Yield(); + yield return value(); + } + } + + private static void AssertCompletionsEqual(ChatCompletion expected, ChatCompletion actual) + { + Assert.Equal(expected.CompletionId, actual.CompletionId); + Assert.Equal(expected.Usage?.InputTokenCount, actual.Usage?.InputTokenCount); + Assert.Equal(expected.Usage?.OutputTokenCount, actual.Usage?.OutputTokenCount); + Assert.Equal(expected.Usage?.TotalTokenCount, actual.Usage?.TotalTokenCount); + Assert.Equal(expected.CreatedAt, actual.CreatedAt); + Assert.Equal(expected.ModelId, actual.ModelId); + Assert.Equal( + JsonSerializer.Serialize(expected.AdditionalProperties, TestJsonSerializerContext.Default.Options), + JsonSerializer.Serialize(actual.AdditionalProperties, TestJsonSerializerContext.Default.Options)); + Assert.Equal(expected.Choices.Count, actual.Choices.Count); + + for (var i = 0; i < expected.Choices.Count; i++) + { + Assert.IsType(expected.Choices[i].GetType(), actual.Choices[i]); + Assert.Equal(expected.Choices[i].Role, actual.Choices[i].Role); + Assert.Equal(expected.Choices[i].Text, actual.Choices[i].Text); + Assert.Equal(expected.Choices[i].Contents.Count, actual.Choices[i].Contents.Count); + + for (var itemIndex = 0; itemIndex < expected.Choices[i].Contents.Count; itemIndex++) + { + var expectedItem = expected.Choices[i].Contents[itemIndex]; + var actualItem = actual.Choices[i].Contents[itemIndex]; + Assert.Equal(expectedItem.ModelId, actualItem.ModelId); + Assert.IsType(expectedItem.GetType(), actualItem); + + if (expectedItem is FunctionCallContent expectedFcc) + { + var actualFcc = (FunctionCallContent)actualItem; + Assert.Equal(expectedFcc.Name, actualFcc.Name); + Assert.Equal(expectedFcc.CallId, actualFcc.CallId); + + // The correct JSON-round-tripping of AIContent/AIContent is not + // the responsibility of CachingChatClient, so not testing that here. + Assert.Equal( + JsonSerializer.Serialize(expectedFcc.Arguments, TestJsonSerializerContext.Default.Options), + JsonSerializer.Serialize(actualFcc.Arguments, TestJsonSerializerContext.Default.Options)); + } + } + } + } + + private static async Task AssertCompletionsEqualAsync(IReadOnlyList expected, IAsyncEnumerable actual) + { + var actualEnumerator = actual.GetAsyncEnumerator(); + + foreach (var expectedItem in expected) + { + Assert.True(await actualEnumerator.MoveNextAsync()); + + var actualItem = actualEnumerator.Current; + Assert.Equal(expectedItem.Text, actualItem.Text); + Assert.Equal(expectedItem.ChoiceIndex, actualItem.ChoiceIndex); + Assert.Equal(expectedItem.Role, actualItem.Role); + Assert.Equal(expectedItem.Contents.Count, actualItem.Contents.Count); + + for (var itemIndex = 0; itemIndex < expectedItem.Contents.Count; itemIndex++) + { + var expectedItemItem = expectedItem.Contents[itemIndex]; + var actualItemItem = actualItem.Contents[itemIndex]; + Assert.IsType(expectedItemItem.GetType(), actualItemItem); + + if (expectedItemItem is FunctionCallContent expectedFcc) + { + var actualFcc = (FunctionCallContent)actualItemItem; + Assert.Equal(expectedFcc.Name, actualFcc.Name); + Assert.Equal(expectedFcc.CallId, actualFcc.CallId); + + // The correct JSON-round-tripping of AIContent/AIContent is not + // the responsibility of CachingChatClient, so not testing that here. + Assert.Equal( + JsonSerializer.Serialize(expectedFcc.Arguments, TestJsonSerializerContext.Default.Options), + JsonSerializer.Serialize(actualFcc.Arguments, TestJsonSerializerContext.Default.Options)); + } + else if (expectedItemItem is UsageContent expectedUsage) + { + var actualUsage = (UsageContent)actualItemItem; + Assert.Equal(expectedUsage.Details.InputTokenCount, actualUsage.Details.InputTokenCount); + Assert.Equal(expectedUsage.Details.OutputTokenCount, actualUsage.Details.OutputTokenCount); + Assert.Equal(expectedUsage.Details.TotalTokenCount, actualUsage.Details.TotalTokenCount); + } + } + } + + Assert.False(await actualEnumerator.MoveNextAsync()); + } + + private sealed class CachingChatClientWithCustomKey(IChatClient innerClient, IDistributedCache storage) + : DistributedCachingChatClient(innerClient, storage) + { + protected override string GetCacheKey(bool streaming, IList chatMessages, ChatOptions? options) + { + var baseKey = base.GetCacheKey(streaming, chatMessages, options); + return baseKey + options?.AdditionalProperties?["someKey"]?.ToString(); + } + } + + public class CustomAIContent1(string text, DateTime date) : AIContent + { + public string Text => text; + public DateTime Date => date; + } + + public class CustomAIContent2(string text, int number) : AIContent + { + public string Text => text; + public int Number => number; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs new file mode 100644 index 00000000000..8ad0c6d7944 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -0,0 +1,352 @@ +// 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 FunctionInvokingChatClientTests +{ + [Fact] + public async Task SupportsSingleFunctionCallPerRequestAsync() + { + var options = new ChatOptions + { + Tools = [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ] + }; + + await InvokeAndAssertAsync(options, [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", "VoidReturn", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SupportsMultipleFunctionCallsPerRequestAsync(bool concurrentInvocation) + { + var options = new ChatOptions + { + Tools = [ + AIFunctionFactory.Create((int i) => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + await InvokeAndAssertAsync(options, [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "Func1"), + new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 34 } }), + new FunctionCallContent("callId3", "Func2", arguments: new Dictionary { { "i", 56 } }), + ]), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("callId1", "Func1", result: "Result 1"), + new FunctionResultContent("callId2", "Func2", result: "Result 2: 34"), + new FunctionResultContent("callId3", "Func2", result: "Result 2: 56"), + ]), + new ChatMessage(ChatRole.Assistant, [ + new FunctionCallContent("callId4", "Func2", arguments: new Dictionary { { "i", 78 } }), + new FunctionCallContent("callId5", "Func1")]), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("callId4", "Func2", result: "Result 2: 78"), + new FunctionResultContent("callId5", "Func1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, "world"), + ], configurePipeline: b => b.Use(s => new FunctionInvokingChatClient(s) { ConcurrentInvocation = concurrentInvocation })); + } + + [Fact] + public async Task ParallelFunctionCallsInvokedConcurrentlyByDefaultAsync() + { + using var barrier = new Barrier(2); + + var options = new ChatOptions + { + Tools = [ + AIFunctionFactory.Create((string arg) => + { + barrier.SignalAndWait(); + return arg + arg; + }, "Func"), + ] + }; + + await InvokeAndAssertAsync(options, [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "Func", arguments: new Dictionary { { "arg", "hello" } }), + new FunctionCallContent("callId2", "Func", arguments: new Dictionary { { "arg", "world" } }), + ]), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("callId1", "Func", result: "hellohello"), + new FunctionResultContent("callId2", "Func", result: "worldworld"), + ]), + new ChatMessage(ChatRole.Assistant, "done"), + ]); + } + + [Fact] + public async Task ConcurrentInvocationOfParallelCallsCanBeDisabledAsync() + { + int activeCount = 0; + + var options = new ChatOptions + { + Tools = [ + AIFunctionFactory.Create(async (string arg) => + { + Interlocked.Increment(ref activeCount); + await Task.Delay(100); + Assert.Equal(1, activeCount); + Interlocked.Decrement(ref activeCount); + return arg + arg; + }, "Func"), + ] + }; + + await InvokeAndAssertAsync(options, [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "Func", arguments: new Dictionary { { "arg", "hello" } }), + new FunctionCallContent("callId2", "Func", arguments: new Dictionary { { "arg", "world" } }), + ]), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("callId1", "Func", result: "hellohello"), + new FunctionResultContent("callId2", "Func", result: "worldworld"), + ]), + new ChatMessage(ChatRole.Assistant, "done"), + ], configurePipeline: b => b.Use(s => new FunctionInvokingChatClient(s) { ConcurrentInvocation = false })); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RemovesFunctionCallingMessagesWhenRequestedAsync(bool keepFunctionCallingMessages) + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ] + }; + +#pragma warning disable SA1118 // Parameter should not span multiple lines + var finalChat = await InvokeAndAssertAsync( + options, + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", "VoidReturn", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ], + expected: keepFunctionCallingMessages ? + null : + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, "world") + ], + configurePipeline: b => b.Use(client => new FunctionInvokingChatClient(client) { KeepFunctionCallingMessages = keepFunctionCallingMessages })); +#pragma warning restore SA1118 + + IEnumerable content = finalChat.SelectMany(m => m.Contents); + if (keepFunctionCallingMessages) + { + Assert.Contains(content, c => c is FunctionCallContent or FunctionResultContent); + } + else + { + Assert.All(content, c => Assert.False(c is FunctionCallContent or FunctionResultContent)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RemovesFunctionCallingContentWhenRequestedAsync(bool keepFunctionCallingMessages) + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ] + }; + +#pragma warning disable SA1118 // Parameter should not span multiple lines + var finalChat = await InvokeAndAssertAsync(options, + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new TextContent("extra"), new FunctionCallContent("callId1", "Func1"), new TextContent("stuff")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } }), new TextContent("more")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", "VoidReturn", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ], + expected: keepFunctionCallingMessages ? + null : + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new TextContent("extra"), new TextContent("stuff")]), + new ChatMessage(ChatRole.Assistant, "more"), + new ChatMessage(ChatRole.Assistant, "world"), + ], + configurePipeline: b => b.Use(client => new FunctionInvokingChatClient(client) { KeepFunctionCallingMessages = keepFunctionCallingMessages })); +#pragma warning restore SA1118 + + IEnumerable content = finalChat.SelectMany(m => m.Contents); + if (keepFunctionCallingMessages) + { + Assert.Contains(content, c => c is FunctionCallContent or FunctionResultContent); + } + else + { + Assert.All(content, c => Assert.False(c is FunctionCallContent or FunctionResultContent)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExceptionDetailsOnlyReportedWhenRequestedAsync(bool detailedErrors) + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(string () => throw new InvalidOperationException("Oh no!"), "Func1"), + ] + }; + + await InvokeAndAssertAsync(options, [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: detailedErrors ? "Error: Function failed. Exception: Oh no!" : "Error: Function failed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ], configurePipeline: b => b.Use(s => new FunctionInvokingChatClient(s) { DetailedErrors = detailedErrors })); + } + + [Fact] + public async Task RejectsMultipleChoicesAsync() + { + var func1 = AIFunctionFactory.Create(() => "Some result 1", "Func1"); + var func2 = AIFunctionFactory.Create(() => "Some result 2", "Func2"); + + using var innerClient = new TestChatClient + { + CompleteAsyncCallback = async (chatContents, options, cancellationToken) => + { + await Task.Yield(); + + return new ChatCompletion( + [ + new(ChatRole.Assistant, [new FunctionCallContent("callId1", func1.Metadata.Name)]), + new(ChatRole.Assistant, [new FunctionCallContent("callId2", func2.Metadata.Name)]), + ]); + } + }; + + IChatClient service = new ChatClientBuilder().UseFunctionInvocation().Use(innerClient); + + List chat = [new ChatMessage(ChatRole.User, "hello")]; + var ex = await Assert.ThrowsAsync( + () => service.CompleteAsync(chat, new ChatOptions { Tools = [func1, func2] })); + + Assert.Contains("only accepts a single choice", ex.Message); + Assert.Single(chat); // It didn't add anything to the chat history + } + + private static async Task> InvokeAndAssertAsync( + ChatOptions options, + List plan, + List? expected = null, + Func? configurePipeline = null) + { + Assert.NotEmpty(plan); + + configurePipeline ??= static b => b.UseFunctionInvocation(); + + using CancellationTokenSource cts = new(); + List chat = [plan[0]]; + int i = 0; + + using var innerClient = new TestChatClient + { + CompleteAsyncCallback = async (contents, actualOptions, actualCancellationToken) => + { + Assert.Same(chat, contents); + Assert.Equal(cts.Token, actualCancellationToken); + + await Task.Yield(); + + return new ChatCompletion([plan[contents.Count]]); + } + }; + + IChatClient service = configurePipeline(new ChatClientBuilder()).Use(innerClient); + + var result = await service.CompleteAsync(chat, options, cts.Token); + chat.Add(result.Message); + + expected ??= plan; + Assert.NotNull(result); + Assert.Equal(expected.Count, chat.Count); + for (; i < expected.Count; i++) + { + var expectedMessage = expected[i]; + var chatMessage = chat[i]; + + Assert.Equal(expectedMessage.Role, chatMessage.Role); + Assert.Equal(expectedMessage.Text, chatMessage.Text); + Assert.Equal(expectedMessage.GetType(), chatMessage.GetType()); + + Assert.Equal(expectedMessage.Contents.Count, chatMessage.Contents.Count); + for (int j = 0; j < expectedMessage.Contents.Count; j++) + { + var expectedItem = expectedMessage.Contents[j]; + var chatItem = chatMessage.Contents[j]; + + Assert.Equal(expectedItem.GetType(), chatItem.GetType()); + Assert.Equal(expectedItem.ToString(), chatItem.ToString()); + if (expectedItem is FunctionCallContent expectedFunctionCall) + { + var chatFunctionCall = (FunctionCallContent)chatItem; + Assert.Equal(expectedFunctionCall.Name, chatFunctionCall.Name); + AssertExtensions.EqualFunctionCallParameters(expectedFunctionCall.Arguments, chatFunctionCall.Arguments); + } + else if (expectedItem is FunctionResultContent expectedFunctionResult) + { + var chatFunctionResult = (FunctionResultContent)chatItem; + AssertExtensions.EqualFunctionCallResults(expectedFunctionResult.Result, chatFunctionResult.Result); + } + } + } + + return chat; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs new file mode 100644 index 00000000000..feb91ac925e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class LoggingChatClientTests +{ + [Fact] + public void LoggingChatClient_InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new LoggingChatClient(null!, NullLogger.Instance)); + Assert.Throws("logger", () => new LoggingChatClient(new TestChatClient(), null!)); + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task CompleteAsync_LogsStartAndCompletion(LogLevel level) + { + using CapturingLoggerProvider clp = new(); + + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(clp).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + using IChatClient innerClient = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => + { + return Task.FromResult(new ChatCompletion([new(ChatRole.Assistant, "blue whale")])); + }, + }; + + using IChatClient client = new ChatClientBuilder(services) + .UseLogging() + .Use(innerClient); + + await client.CompleteAsync( + [new(ChatRole.User, "What's the biggest animal?")], + new ChatOptions { FrequencyPenalty = 3.0f }); + + if (level is LogLevel.Trace) + { + Assert.Collection(clp.Logger.Entries, + entry => Assert.True(entry.Message.Contains("CompleteAsync invoked:") && entry.Message.Contains("biggest animal")), + entry => Assert.True(entry.Message.Contains("CompleteAsync completed:") && entry.Message.Contains("blue whale"))); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(clp.Logger.Entries, + entry => Assert.True(entry.Message.Contains("CompleteAsync invoked.") && !entry.Message.Contains("biggest animal")), + entry => Assert.True(entry.Message.Contains("CompleteAsync completed.") && !entry.Message.Contains("blue whale"))); + } + else + { + Assert.Empty(clp.Logger.Entries); + } + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task CompleteStreamAsync_LogsStartUpdateCompletion(LogLevel level) + { + CapturingLogger logger = new(level); + + using IChatClient innerClient = new TestChatClient + { + CompleteStreamingAsyncCallback = (messages, options, cancellationToken) => GetUpdatesAsync() + }; + + static async IAsyncEnumerable GetUpdatesAsync() + { + await Task.Yield(); + yield return new StreamingChatCompletionUpdate { Role = ChatRole.Assistant, Text = "blue " }; + yield return new StreamingChatCompletionUpdate { Role = ChatRole.Assistant, Text = "whale" }; + } + + using IChatClient client = new ChatClientBuilder() + .UseLogging(logger) + .Use(innerClient); + + await foreach (var update in client.CompleteStreamingAsync( + [new(ChatRole.User, "What's the biggest animal?")], + new ChatOptions { FrequencyPenalty = 3.0f })) + { + // nop + } + + if (level is LogLevel.Trace) + { + Assert.Collection(logger.Entries, + entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync invoked:") && entry.Message.Contains("biggest animal")), + entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync received update:") && entry.Message.Contains("blue")), + entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync received update:") && entry.Message.Contains("whale")), + entry => Assert.Contains("CompleteStreamingAsync completed.", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logger.Entries, + entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync invoked.") && !entry.Message.Contains("biggest animal")), + entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync received update.") && !entry.Message.Contains("blue")), + entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync received update.") && !entry.Message.Contains("whale")), + entry => Assert.Contains("CompleteStreamingAsync completed.", entry.Message)); + } + else + { + Assert.Empty(logger.Entries); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs new file mode 100644 index 00000000000..d0056b21b91 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs @@ -0,0 +1,220 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using OpenTelemetry.Trace; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenTelemetryChatClientTests +{ + [Fact] + public async Task ExpectedInformationLogged_NonStreaming_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var innerClient = new TestChatClient + { + Metadata = new("testservice", new Uri("http://localhost:12345/something"), "amazingmodel"), + CompleteAsyncCallback = async (messages, options, cancellationToken) => + { + await Task.Yield(); + return new ChatCompletion([new ChatMessage(ChatRole.Assistant, "blue whale")]) + { + CompletionId = "id123", + FinishReason = ChatFinishReason.Stop, + Usage = new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 42, + }, + }; + } + }; + + var chatClient = new ChatClientBuilder() + .UseOpenTelemetry(sourceName, instance => + { + instance.EnableSensitiveData = true; + instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; + }) + .Use(innerClient); + + await chatClient.CompleteAsync( + [new(ChatRole.User, "What's the biggest animal?")], + new ChatOptions + { + FrequencyPenalty = 3.0f, + MaxOutputTokens = 123, + ModelId = "replacementmodel", + TopP = 4.0f, + PresencePenalty = 5.0f, + ResponseFormat = ChatResponseFormat.Json, + Temperature = 6.0f, + StopSequences = ["hello", "world"], + AdditionalProperties = new() { ["top_k"] = 7.0f }, + }); + + var activity = Assert.Single(activities); + + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + + Assert.Equal("http://localhost:12345/something", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); + + Assert.Equal("chat.completions replacementmodel", activity.DisplayName); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.system")); + + Assert.Equal("replacementmodel", activity.GetTagItem("gen_ai.request.model")); + Assert.Equal(3.0f, activity.GetTagItem("gen_ai.request.frequency_penalty")); + Assert.Equal(4.0f, activity.GetTagItem("gen_ai.request.top_p")); + Assert.Equal(5.0f, activity.GetTagItem("gen_ai.request.presence_penalty")); + Assert.Equal(6.0f, activity.GetTagItem("gen_ai.request.temperature")); + Assert.Equal(7.0, activity.GetTagItem("gen_ai.request.top_k")); + Assert.Equal(123, activity.GetTagItem("gen_ai.request.max_tokens")); + Assert.Equal("""["hello", "world"]""", activity.GetTagItem("gen_ai.request.stop_sequences")); + + Assert.Equal("id123", activity.GetTagItem("gen_ai.response.id")); + Assert.Equal("""["stop"]""", activity.GetTagItem("gen_ai.response.finish_reasons")); + Assert.Equal(10, activity.GetTagItem("gen_ai.response.input_tokens")); + Assert.Equal(20, activity.GetTagItem("gen_ai.response.output_tokens")); + + Assert.Collection(activity.Events, + evt => + { + Assert.Equal("gen_ai.content.prompt", evt.Name); + Assert.Equal("""[{"role": "user", "content": "What\u0027s the biggest animal?"}]""", evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.prompt").Value); + }, + evt => + { + Assert.Equal("gen_ai.content.completion", evt.Name); + Assert.Contains("whale", (string)evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.completion").Value!); + }); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + } + + [Fact] + public async Task ExpectedInformationLogged_Streaming_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + async static IAsyncEnumerable CallbackAsync( + IList messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + yield return new StreamingChatCompletionUpdate + { + Role = ChatRole.Assistant, + Text = "blue ", + CompletionId = "id123", + }; + await Task.Yield(); + yield return new StreamingChatCompletionUpdate + { + Role = ChatRole.Assistant, + Text = "whale", + FinishReason = ChatFinishReason.Stop, + }; + yield return new StreamingChatCompletionUpdate + { + Contents = [new UsageContent(new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 42, + })], + }; + } + + using var innerClient = new TestChatClient + { + Metadata = new("testservice", new Uri("http://localhost:12345/something"), "amazingmodel"), + CompleteStreamingAsyncCallback = CallbackAsync, + }; + + var chatClient = new ChatClientBuilder() + .UseOpenTelemetry(sourceName, instance => + { + instance.EnableSensitiveData = true; + instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; + }) + .Use(innerClient); + + await foreach (var update in chatClient.CompleteStreamingAsync( + [new(ChatRole.User, "What's the biggest animal?")], + new ChatOptions + { + FrequencyPenalty = 3.0f, + MaxOutputTokens = 123, + ModelId = "replacementmodel", + TopP = 4.0f, + PresencePenalty = 5.0f, + ResponseFormat = ChatResponseFormat.Json, + Temperature = 6.0f, + StopSequences = ["hello", "world"], + AdditionalProperties = new() { ["top_k"] = 7.0 }, + })) + { + // Drain the stream. + } + + var activity = Assert.Single(activities); + + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + + Assert.Equal("http://localhost:12345/something", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); + + Assert.Equal("chat.completions replacementmodel", activity.DisplayName); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.system")); + + Assert.Equal("replacementmodel", activity.GetTagItem("gen_ai.request.model")); + Assert.Equal(3.0f, activity.GetTagItem("gen_ai.request.frequency_penalty")); + Assert.Equal(4.0f, activity.GetTagItem("gen_ai.request.top_p")); + Assert.Equal(5.0f, activity.GetTagItem("gen_ai.request.presence_penalty")); + Assert.Equal(6.0f, activity.GetTagItem("gen_ai.request.temperature")); + Assert.Equal(7.0, activity.GetTagItem("gen_ai.request.top_k")); + Assert.Equal(123, activity.GetTagItem("gen_ai.request.max_tokens")); + Assert.Equal("""["hello", "world"]""", activity.GetTagItem("gen_ai.request.stop_sequences")); + + Assert.Equal("id123", activity.GetTagItem("gen_ai.response.id")); + Assert.Equal("""["stop"]""", activity.GetTagItem("gen_ai.response.finish_reasons")); + Assert.Equal(10, activity.GetTagItem("gen_ai.response.input_tokens")); + Assert.Equal(20, activity.GetTagItem("gen_ai.response.output_tokens")); + + Assert.Collection(activity.Events, + evt => + { + Assert.Equal("gen_ai.content.prompt", evt.Name); + Assert.Equal("""[{"role": "user", "content": "What\u0027s the biggest animal?"}]""", evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.prompt").Value); + }, + evt => + { + Assert.Equal("gen_ai.content.completion", evt.Name); + Assert.Contains("whale", (string)evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.completion").Value!); + }); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ScopedChatClientExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ScopedChatClientExtensions.cs new file mode 100644 index 00000000000..d9ad92dc266 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ScopedChatClientExtensions.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public static class ScopedChatClientExtensions +{ + public static ChatClientBuilder UseScopedMiddleware(this ChatClientBuilder builder) + => builder.Use((services, inner) + => new DependencyInjectionPatterns.ScopedChatClient(services, inner)); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs new file mode 100644 index 00000000000..2b4370222c6 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs @@ -0,0 +1,348 @@ +// 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.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DistributedCachingEmbeddingGeneratorTest +{ + private readonly TestInMemoryCacheStorage _storage = new(); + private readonly Embedding _expectedEmbedding = new(new float[] { 1.0f, 2.0f, 3.0f }) + { + CreatedAt = DateTimeOffset.Parse("2024-08-01T00:00:00Z"), + ModelId = "someModel", + AdditionalProperties = new() { ["a"] = "b" }, + }; + + [Fact] + public async Task CachesSuccessResultsAsync() + { + // Arrange + + // Verify that all the expected properties will round-trip through the cache, + // even if this involves serialization + var innerCallCount = 0; + using var testGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = (values, options, cancellationToken) => + { + innerCallCount++; + return Task.FromResult>>([_expectedEmbedding]); + }, + }; + using var outer = new DistributedCachingEmbeddingGenerator>(testGenerator, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options, + }; + + // Make the initial request and do a quick sanity check + var result1 = await outer.GenerateAsync("abc"); + Assert.Single(result1); + AssertEmbeddingsEqual(_expectedEmbedding, result1[0]); + Assert.Equal(1, innerCallCount); + + // Act + var result2 = await outer.GenerateAsync("abc"); + + // Assert + Assert.Single(result2); + Assert.Equal(1, innerCallCount); + AssertEmbeddingsEqual(_expectedEmbedding, result2[0]); + + // Act/Assert 2: Cache misses do not return cached results + await outer.GenerateAsync(["def"]); + Assert.Equal(2, innerCallCount); + } + + [Fact] + public async Task SupportsPartiallyCachedBatchesAsync() + { + // Arrange + + // Verify that all the expected properties will round-trip through the cache, + // even if this involves serialization + var innerCallCount = 0; + Embedding[] expected = Enumerable.Range(0, 10).Select(i => + new Embedding(new[] { 1.0f, 2.0f, 3.0f }) + { + CreatedAt = DateTimeOffset.Parse("2024-08-01T00:00:00Z") + TimeSpan.FromHours(i), + ModelId = $"someModel{i}", + AdditionalProperties = new() { [$"a{i}"] = $"b{i}" }, + }).ToArray(); + using var testGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = (values, options, cancellationToken) => + { + innerCallCount++; + Assert.Equal(innerCallCount == 1 ? 4 : 6, values.Count()); + return Task.FromResult>>(new(values.Select(i => expected[int.Parse(i)]))); + }, + }; + using var outer = new DistributedCachingEmbeddingGenerator>(testGenerator, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options, + }; + + // Make initial requests for some of the values + var results = await outer.GenerateAsync(["0", "4", "5", "8"]); + Assert.Equal(1, innerCallCount); + Assert.Equal(4, results.Count); + AssertEmbeddingsEqual(expected[0], results[0]); + AssertEmbeddingsEqual(expected[4], results[1]); + AssertEmbeddingsEqual(expected[5], results[2]); + AssertEmbeddingsEqual(expected[8], results[3]); + + // Act/Assert + results = await outer.GenerateAsync(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]); + Assert.Equal(2, innerCallCount); + for (int i = 0; i < 10; i++) + { + AssertEmbeddingsEqual(expected[i], results[i]); + } + + results = await outer.GenerateAsync(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]); + Assert.Equal(2, innerCallCount); + for (int i = 0; i < 10; i++) + { + AssertEmbeddingsEqual(expected[i], results[i]); + } + } + + [Fact] + public async Task AllowsConcurrentCallsAsync() + { + // Arrange + var innerCallCount = 0; + var completionTcs = new TaskCompletionSource(); + using var innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = async (value, options, cancellationToken) => + { + innerCallCount++; + await completionTcs.Task; + return [_expectedEmbedding]; + } + }; + using var outer = new DistributedCachingEmbeddingGenerator>(innerGenerator, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options, + }; + + // Act 1: Concurrent calls before resolution are passed into the inner client + var result1 = outer.GenerateAsync("abc"); + var result2 = outer.GenerateAsync("abc"); + + // Assert 1 + Assert.Equal(2, innerCallCount); + Assert.False(result1.IsCompleted); + Assert.False(result2.IsCompleted); + completionTcs.SetResult(true); + AssertEmbeddingsEqual(_expectedEmbedding, (await result1)[0]); + AssertEmbeddingsEqual(_expectedEmbedding, (await result2)[0]); + + // Act 2: Subsequent calls after completion are resolved from the cache + var result3 = await outer.GenerateAsync("abc"); + Assert.Equal(2, innerCallCount); + AssertEmbeddingsEqual(_expectedEmbedding, (await result1)[0]); + } + + [Fact] + public async Task DoesNotCacheExceptionResultsAsync() + { + // Arrange + var innerCallCount = 0; + using var innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = (value, options, cancellationToken) => + { + innerCallCount++; + throw new InvalidTimeZoneException("some failure"); + } + }; + using var outer = new DistributedCachingEmbeddingGenerator>(innerGenerator, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options, + }; + + var ex1 = await Assert.ThrowsAsync(() => outer.GenerateAsync("abc")); + Assert.Equal("some failure", ex1.Message); + Assert.Equal(1, innerCallCount); + + // Act + var ex2 = await Assert.ThrowsAsync(() => outer.GenerateAsync("abc")); + + // Assert + Assert.NotSame(ex1, ex2); + Assert.Equal("some failure", ex2.Message); + Assert.Equal(2, innerCallCount); + } + + [Fact] + public async Task DoesNotCacheCanceledResultsAsync() + { + // Arrange + var innerCallCount = 0; + var resolutionTcs = new TaskCompletionSource(); + using var innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = async (value, options, cancellationToken) => + { + innerCallCount++; + if (innerCallCount == 1) + { + await resolutionTcs.Task; + } + + return [_expectedEmbedding]; + } + }; + using var outer = new DistributedCachingEmbeddingGenerator>(innerGenerator, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options, + }; + + // First call gets cancelled + var result1 = outer.GenerateAsync("abc"); + Assert.False(result1.IsCompleted); + Assert.Equal(1, innerCallCount); + resolutionTcs.SetCanceled(); + await Assert.ThrowsAnyAsync(() => result1); + Assert.True(result1.IsCanceled); + + // Act/Assert: Second call can succeed + var result2 = await outer.GenerateAsync("abc"); + Assert.Single(result2); + Assert.Equal(2, innerCallCount); + AssertEmbeddingsEqual(_expectedEmbedding, result2[0]); + } + + [Fact] + public async Task CacheKeyDoesNotVaryByEmbeddingOptionsAsync() + { + // Arrange + var innerCallCount = 0; + var completionTcs = new TaskCompletionSource(); + using var innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = async (value, options, cancellationToken) => + { + innerCallCount++; + await Task.Yield(); + return [_expectedEmbedding]; + } + }; + using var outer = new DistributedCachingEmbeddingGenerator>(innerGenerator, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options, + }; + + // Act: Call with two different options + var result1 = await outer.GenerateAsync("abc", new EmbeddingGenerationOptions + { + AdditionalProperties = new() { ["someKey"] = "value 1" } + }); + var result2 = await outer.GenerateAsync("abc", new EmbeddingGenerationOptions + { + AdditionalProperties = new() { ["someKey"] = "value 2" } + }); + + // Assert: Same result + Assert.Single(result1); + Assert.Single(result2); + Assert.Equal(1, innerCallCount); + AssertEmbeddingsEqual(_expectedEmbedding, result1[0]); + AssertEmbeddingsEqual(_expectedEmbedding, result2[0]); + } + + [Fact] + public async Task SubclassCanOverrideCacheKeyToVaryByOptionsAsync() + { + // Arrange + var innerCallCount = 0; + var completionTcs = new TaskCompletionSource(); + using var innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = async (value, options, cancellationToken) => + { + innerCallCount++; + await Task.Yield(); + return [_expectedEmbedding]; + } + }; + using var outer = new CachingEmbeddingGeneratorWithCustomKey(innerGenerator, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options, + }; + + // Act: Call with two different options + var result1 = await outer.GenerateAsync("abc", new EmbeddingGenerationOptions + { + AdditionalProperties = new() { ["someKey"] = "value 1" } + }); + var result2 = await outer.GenerateAsync("abc", new EmbeddingGenerationOptions + { + AdditionalProperties = new() { ["someKey"] = "value 2" } + }); + + // Assert: Different results + Assert.Single(result1); + Assert.Single(result2); + Assert.Equal(2, innerCallCount); + AssertEmbeddingsEqual(_expectedEmbedding, result1[0]); + AssertEmbeddingsEqual(_expectedEmbedding, result2[0]); + } + + [Fact] + public async Task CanResolveIDistributedCacheFromDI() + { + // Arrange + var services = new ServiceCollection() + .AddSingleton(_storage) + .BuildServiceProvider(); + using var testGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = (values, options, cancellationToken) => + { + return Task.FromResult>>([_expectedEmbedding]); + }, + }; + using var outer = new EmbeddingGeneratorBuilder>(services) + .UseDistributedCache(configure: instance => + { + instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; + }) + .Use(testGenerator); + + // Act: Make a request that should populate the cache + Assert.Empty(_storage.Keys); + var result = await outer.GenerateAsync("abc"); + + // Assert + Assert.NotNull(result); + Assert.Single(_storage.Keys); + } + + private static void AssertEmbeddingsEqual(Embedding expected, Embedding actual) + { + Assert.Equal(expected.CreatedAt, actual.CreatedAt); + Assert.Equal(expected.ModelId, actual.ModelId); + Assert.Equal(expected.Vector.ToArray(), actual.Vector.ToArray()); + Assert.Equal( + JsonSerializer.Serialize(expected.AdditionalProperties, TestJsonSerializerContext.Default.Options), + JsonSerializer.Serialize(actual.AdditionalProperties, TestJsonSerializerContext.Default.Options)); + } + + private sealed class CachingEmbeddingGeneratorWithCustomKey(IEmbeddingGenerator> innerGenerator, IDistributedCache storage) + : DistributedCachingEmbeddingGenerator>(innerGenerator, storage) + { + protected override string GetCacheKey(string value, EmbeddingGenerationOptions? options) => + base.GetCacheKey(value, options) + options?.AdditionalProperties?["someKey"]?.ToString(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs new file mode 100644 index 00000000000..357168c3b65 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class EmbeddingGeneratorBuilderTests +{ + [Fact] + public void PassesServiceProviderToFactories() + { + var expectedServiceProvider = new ServiceCollection().BuildServiceProvider(); + using var expectedResult = new TestEmbeddingGenerator(); + var builder = new EmbeddingGeneratorBuilder>(expectedServiceProvider); + + builder.Use((serviceProvider, innerClient) => + { + Assert.Same(expectedServiceProvider, serviceProvider); + return expectedResult; + }); + + using var innerGenerator = new TestEmbeddingGenerator(); + Assert.Equal(expectedResult, builder.Use(innerGenerator)); + } + + [Fact] + public void BuildsPipelineInOrderAdded() + { + // Arrange + using var expectedInnerService = new TestEmbeddingGenerator(); + var builder = new EmbeddingGeneratorBuilder>(); + + builder.Use(next => new InnerServiceCapturingEmbeddingGenerator("First", next)); + builder.Use(next => new InnerServiceCapturingEmbeddingGenerator("Second", next)); + builder.Use(next => new InnerServiceCapturingEmbeddingGenerator("Third", next)); + + // Act + var first = (InnerServiceCapturingEmbeddingGenerator)builder.Use(expectedInnerService); + + // Assert + Assert.Equal("First", first.Name); + var second = (InnerServiceCapturingEmbeddingGenerator)first.InnerGenerator; + Assert.Equal("Second", second.Name); + var third = (InnerServiceCapturingEmbeddingGenerator)second.InnerGenerator; + Assert.Equal("Third", third.Name); + Assert.Same(expectedInnerService, third.InnerGenerator); + } + + [Fact] + public void DoesNotAcceptNullInnerService() + { + Assert.Throws(() => new EmbeddingGeneratorBuilder>().Use((IEmbeddingGenerator>)null!)); + } + + [Fact] + public void DoesNotAcceptNullFactories() + { + var builder = new EmbeddingGeneratorBuilder>(); + Assert.Throws(() => builder.Use((Func>, IEmbeddingGenerator>>)null!)); + Assert.Throws(() => builder.Use((Func>, IEmbeddingGenerator>>)null!)); + } + + [Fact] + public void DoesNotAllowFactoriesToReturnNull() + { + var builder = new EmbeddingGeneratorBuilder>(); + builder.Use(_ => null!); + var ex = Assert.Throws(() => builder.Use(new TestEmbeddingGenerator())); + Assert.Contains("entry at index 0", ex.Message); + } + + private sealed class InnerServiceCapturingEmbeddingGenerator(string name, IEmbeddingGenerator> innerGenerator) : + DelegatingEmbeddingGenerator>(innerGenerator) + { +#pragma warning disable S3604 // False positive: Member initializer values should not be redundant + public string Name { get; } = name; +#pragma warning restore S3604 + public new IEmbeddingGenerator> InnerGenerator => base.InnerGenerator; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs new file mode 100644 index 00000000000..e231e8995fe --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class LoggingEmbeddingGeneratorTests +{ + [Fact] + public void LoggingEmbeddingGenerator_InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new LoggingEmbeddingGenerator>(null!, NullLogger.Instance)); + Assert.Throws("logger", () => new LoggingEmbeddingGenerator>(new TestEmbeddingGenerator(), null!)); + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task CompleteAsync_LogsStartAndCompletion(LogLevel level) + { + using CapturingLoggerProvider clp = new(); + + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(clp).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + using IEmbeddingGenerator> innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = (values, options, cancellationToken) => + { + return Task.FromResult(new GeneratedEmbeddings>([new Embedding(new float[] { 1f, 2f, 3f })])); + }, + }; + + using IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>(services) + .UseLogging() + .Use(innerGenerator); + + await generator.GenerateAsync("Blue whale"); + + if (level is LogLevel.Trace) + { + Assert.Collection(clp.Logger.Entries, + entry => Assert.True(entry.Message.Contains("GenerateAsync invoked:") && entry.Message.Contains("Blue whale")), + entry => Assert.Contains("GenerateAsync generated 1 embedding(s).", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(clp.Logger.Entries, + entry => Assert.True(entry.Message.Contains("GenerateAsync invoked.") && !entry.Message.Contains("Blue whale")), + entry => Assert.Contains("GenerateAsync generated 1 embedding(s).", entry.Message)); + } + else + { + Assert.Empty(clp.Logger.Entries); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs new file mode 100644 index 00000000000..41ed51cd2a2 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.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.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIFunctionFactoryTest +{ + [Fact] + public void InvalidArguments_Throw() + { + Delegate nullDelegate = null!; + Assert.Throws(() => AIFunctionFactory.Create(nullDelegate)); + Assert.Throws(() => AIFunctionFactory.Create((MethodInfo)null!)); + Assert.Throws(() => AIFunctionFactory.Create(typeof(AIFunctionFactoryTest).GetMethod(nameof(InvalidArguments_Throw))!, null)); + Assert.Throws(() => AIFunctionFactory.Create(typeof(List<>).GetMethod("Add")!, new List())); + } + + [Fact] + public async Task Parameters_MappedByName_Async() + { + AIFunction func; + + func = AIFunctionFactory.Create((string a) => a + " " + a); + AssertExtensions.EqualFunctionCallResults("test test", await func.InvokeAsync([new KeyValuePair("a", "test")])); + + func = AIFunctionFactory.Create((string a, string b) => b + " " + a); + AssertExtensions.EqualFunctionCallResults("hello world", await func.InvokeAsync([new KeyValuePair("b", "hello"), new KeyValuePair("a", "world")])); + + func = AIFunctionFactory.Create((int a, long b) => a + b); + AssertExtensions.EqualFunctionCallResults(3L, await func.InvokeAsync([new KeyValuePair("a", 1), new KeyValuePair("b", 2L)])); + } + + [Fact] + public async Task Parameters_DefaultValuesAreUsedButOverridable_Async() + { + AIFunction func = AIFunctionFactory.Create((string a = "test") => a + " " + a); + AssertExtensions.EqualFunctionCallResults("test test", await func.InvokeAsync()); + AssertExtensions.EqualFunctionCallResults("hello hello", await func.InvokeAsync([new KeyValuePair("a", "hello")])); + } + + [Fact] + public async Task Parameters_AIFunctionContextMappedByType_Async() + { + using var cts = new CancellationTokenSource(); + CancellationToken written; + AIFunction func; + + // As the only parameter + written = default; + func = AIFunctionFactory.Create((AIFunctionContext ctx) => + { + Assert.NotNull(ctx); + written = ctx.CancellationToken; + }); + AssertExtensions.EqualFunctionCallResults(null, await func.InvokeAsync(cancellationToken: cts.Token)); + Assert.Equal(cts.Token, written); + + // As the last + written = default; + func = AIFunctionFactory.Create((int somethingFirst, AIFunctionContext ctx) => + { + Assert.NotNull(ctx); + written = ctx.CancellationToken; + }); + AssertExtensions.EqualFunctionCallResults(null, await func.InvokeAsync(new Dictionary { ["somethingFirst"] = 1, ["ctx"] = new AIFunctionContext() }, cts.Token)); + Assert.Equal(cts.Token, written); + + // As the first + written = default; + func = AIFunctionFactory.Create((AIFunctionContext ctx, int somethingAfter = 0) => + { + Assert.NotNull(ctx); + written = ctx.CancellationToken; + }); + AssertExtensions.EqualFunctionCallResults(null, await func.InvokeAsync(cancellationToken: cts.Token)); + Assert.Equal(cts.Token, written); + } + + [Fact] + public async Task Returns_AsyncReturnTypesSupported_Async() + { + AIFunction func; + + func = AIFunctionFactory.Create(Task (string a) => Task.FromResult(a + " " + a)); + AssertExtensions.EqualFunctionCallResults("test test", await func.InvokeAsync([new KeyValuePair("a", "test")])); + + func = AIFunctionFactory.Create(ValueTask (string a, string b) => new ValueTask(b + " " + a)); + AssertExtensions.EqualFunctionCallResults("hello world", await func.InvokeAsync([new KeyValuePair("b", "hello"), new KeyValuePair("a", "world")])); + + long result = 0; + func = AIFunctionFactory.Create(async Task (int a, long b) => { result = a + b; await Task.Yield(); }); + AssertExtensions.EqualFunctionCallResults(null, await func.InvokeAsync([new KeyValuePair("a", 1), new KeyValuePair("b", 2L)])); + Assert.Equal(3, result); + + result = 0; + func = AIFunctionFactory.Create(async ValueTask (int a, long b) => { result = a + b; await Task.Yield(); }); + AssertExtensions.EqualFunctionCallResults(null, await func.InvokeAsync([new KeyValuePair("a", 1), new KeyValuePair("b", 2L)])); + Assert.Equal(3, result); + + func = AIFunctionFactory.Create((int count) => SimpleIAsyncEnumerable(count)); + AssertExtensions.EqualFunctionCallResults(new int[] { 0, 1, 2, 3, 4 }, await func.InvokeAsync([new("count", 5)])); + + static async IAsyncEnumerable SimpleIAsyncEnumerable(int count) + { + for (int i = 0; i < count; i++) + { + await Task.Yield(); + yield return i; + } + } + + func = AIFunctionFactory.Create(() => (IAsyncEnumerable)new ThrowingAsyncEnumerable()); + await Assert.ThrowsAsync(() => func.InvokeAsync()); + } + + private sealed class ThrowingAsyncEnumerable : IAsyncEnumerable + { +#pragma warning disable S3717 // Track use of "NotImplementedException" + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => throw new NotImplementedException(); +#pragma warning restore S3717 // Track use of "NotImplementedException" + } + + [Fact] + public void Metadata_DerivedFromLambda() + { + AIFunction func; + + func = AIFunctionFactory.Create(() => "test"); + Assert.Contains("Metadata_DerivedFromLambda", func.Metadata.Name); + Assert.Empty(func.Metadata.Description); + Assert.Empty(func.Metadata.Parameters); + Assert.Equal(typeof(string), func.Metadata.ReturnParameter.ParameterType); + + func = AIFunctionFactory.Create((string a) => a + " " + a); + Assert.Contains("Metadata_DerivedFromLambda", func.Metadata.Name); + Assert.Empty(func.Metadata.Description); + Assert.Single(func.Metadata.Parameters); + + func = AIFunctionFactory.Create( + [Description("This is a test function")] ([Description("This is A")] string a, [Description("This is B")] string b) => b + " " + a); + Assert.Contains("Metadata_DerivedFromLambda", func.Metadata.Name); + Assert.Equal("This is a test function", func.Metadata.Description); + Assert.Collection(func.Metadata.Parameters, + p => Assert.Equal("This is A", p.Description), + p => Assert.Equal("This is B", p.Description)); + } + + [Fact] + public void AIFunctionFactoryCreateOptions_ValuesPropagateToAIFunction() + { + IReadOnlyList parameterMetadata = [new AIFunctionParameterMetadata("a")]; + AIFunctionReturnParameterMetadata returnParameterMetadata = new() { ParameterType = typeof(string) }; + IReadOnlyDictionary metadata = new Dictionary { ["a"] = "b" }; + + var options = new AIFunctionFactoryCreateOptions + { + Name = "test name", + Description = "test description", + Parameters = parameterMetadata, + ReturnParameter = returnParameterMetadata, + AdditionalProperties = metadata, + }; + + Assert.Equal("test name", options.Name); + Assert.Equal("test description", options.Description); + Assert.Same(parameterMetadata, options.Parameters); + Assert.Same(returnParameterMetadata, options.ReturnParameter); + Assert.Same(metadata, options.AdditionalProperties); + + AIFunction func = AIFunctionFactory.Create(() => { }, options); + + Assert.Equal("test name", func.Metadata.Name); + Assert.Equal("test description", func.Metadata.Description); + Assert.Equal(parameterMetadata, func.Metadata.Parameters); + Assert.Equal(returnParameterMetadata, func.Metadata.ReturnParameter); + Assert.Equal(metadata, func.Metadata.AdditionalProperties); + } +} 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 new file mode 100644 index 00000000000..b3d5e8048f5 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj @@ -0,0 +1,32 @@ + + + Microsoft.Extensions.AI + Unit tests for Microsoft.Extensions.AI. + + + + $(NoWarn);CA1063;CA1861;SA1130;VSTHRD003 + true + + + + true + + + + + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/TestInMemoryCacheStorage.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/TestInMemoryCacheStorage.cs new file mode 100644 index 00000000000..8ab2cd0cbb0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/TestInMemoryCacheStorage.cs @@ -0,0 +1,51 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; + +namespace Microsoft.Extensions.AI; + +internal sealed class TestInMemoryCacheStorage : IDistributedCache +{ + private readonly ConcurrentDictionary _storage = new(); + + public ICollection Keys => _storage.Keys; + + public byte[]? Get(string key) + => _storage.TryGetValue(key, out var value) ? value : null; + + public Task GetAsync(string key, CancellationToken token = default) + => Task.FromResult(Get(key)); + + public void Refresh(string key) + { + // In memory, nothing to refresh + } + + public Task RefreshAsync(string key, CancellationToken token = default) + => Task.CompletedTask; + + public void Remove(string key) + => _storage.TryRemove(key, out _); + + public Task RemoveAsync(string key, CancellationToken token = default) + { + Remove(key); + return Task.CompletedTask; + } + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) + { + _storage[key] = value; + } + + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + Set(key, value, options); + return Task.CompletedTask; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/TestJsonSerializerContext.cs new file mode 100644 index 00000000000..e376da86dad --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/TestJsonSerializerContext.cs @@ -0,0 +1,28 @@ +// 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 System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +// These types are directly serialized by DistributedCachingChatClient +[JsonSerializable(typeof(ChatCompletion))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(IReadOnlyList))] + +// These types are specific to the tests in this project +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(Embedding))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(DayOfWeek[]))] +[JsonSerializable(typeof(Guid))] +internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; diff --git a/test/TestUtilities/XUnit/ConditionalFactDiscoverer.cs b/test/TestUtilities/XUnit/ConditionalFactDiscoverer.cs index a27876703e7..e007d95860a 100644 --- a/test/TestUtilities/XUnit/ConditionalFactDiscoverer.cs +++ b/test/TestUtilities/XUnit/ConditionalFactDiscoverer.cs @@ -24,6 +24,7 @@ protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions var skipReason = testMethod.EvaluateSkipConditions(); return skipReason != null ? new SkippedTestCase(skipReason, _diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), TestMethodDisplayOptions.None, testMethod) - : base.CreateTestCase(discoveryOptions, testMethod, factAttribute); + : new SkippedFactTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), + discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod); // Test case skippable at runtime. } } diff --git a/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs b/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs index 846038f8786..b1e53b8ed77 100644 --- a/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs +++ b/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs @@ -3,7 +3,6 @@ // Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ -using System; using System.Collections.Generic; using Xunit.Abstractions; using Xunit.Sdk; diff --git a/test/TestUtilities/XUnit/SkipTestException.cs b/test/TestUtilities/XUnit/SkipTestException.cs new file mode 100644 index 00000000000..70f7d53c7d8 --- /dev/null +++ b/test/TestUtilities/XUnit/SkipTestException.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Borrowed from https://github.com/dotnet/aspnetcore/blob/95ed45c67/src/Testing/src/xunit/ + +using System; + +namespace Microsoft.TestUtilities; + +public class SkipTestException : Exception +{ + public SkipTestException(string reason) + : base(reason) + { + } +} diff --git a/test/TestUtilities/XUnit/SkippedFactTestCase.cs b/test/TestUtilities/XUnit/SkippedFactTestCase.cs new file mode 100644 index 00000000000..79ace15ea6e --- /dev/null +++ b/test/TestUtilities/XUnit/SkippedFactTestCase.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.TestUtilities; + +public class SkippedFactTestCase : XunitTestCase +{ + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes", error: true)] + public SkippedFactTestCase() + { + } + + public SkippedFactTestCase( + IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, + ITestMethod testMethod, object[]? testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) + { + } + + public override async Task RunAsync(IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + object[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + { + using SkippedTestMessageBus skipMessageBus = new(messageBus); + var result = await base.RunAsync(diagnosticMessageSink, skipMessageBus, constructorArguments, aggregator, cancellationTokenSource); + if (skipMessageBus.SkippedTestCount > 0) + { + result.Failed -= skipMessageBus.SkippedTestCount; + result.Skipped += skipMessageBus.SkippedTestCount; + } + + return result; + } +} diff --git a/test/TestUtilities/XUnit/SkippedTestMessageBus.cs b/test/TestUtilities/XUnit/SkippedTestMessageBus.cs new file mode 100644 index 00000000000..230586852b8 --- /dev/null +++ b/test/TestUtilities/XUnit/SkippedTestMessageBus.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.TestUtilities; + +/// Implements message bus to communicate tests skipped via SkipTestException. +public sealed class SkippedTestMessageBus : IMessageBus +{ + private readonly IMessageBus _innerBus; + + public SkippedTestMessageBus(IMessageBus innerBus) + { + _innerBus = innerBus; + } + + public int SkippedTestCount { get; private set; } + + public void Dispose() + { + // nothing to dispose + } + + public bool QueueMessage(IMessageSinkMessage message) + { + var testFailed = message as ITestFailed; + + if (testFailed != null) + { + var exceptionType = testFailed.ExceptionTypes.FirstOrDefault(); + if (exceptionType == typeof(SkipTestException).FullName) + { + SkippedTestCount++; + return _innerBus.QueueMessage(new TestSkipped(testFailed.Test, testFailed.Messages.FirstOrDefault())); + } + } + + // Nothing we care about, send it on its way + return _innerBus.QueueMessage(message); + } +} From e5bbd336e678188d587be1cbae051d872492f2e6 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 8 Oct 2024 13:00:44 -0400 Subject: [PATCH 009/190] Temporarily work around trimming-related warnings --- .../ChatClientStructuredOutputExtensions.cs | 2 ++ .../Functions/AIFunctionFactory.cs | 10 ++++++++++ src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs | 4 ++-- .../Microsoft.Extensions.AI.csproj | 5 +++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index 2a8b794c50e..5d16440a8fa 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -68,6 +68,8 @@ public static Task> CompleteAsync( /// The type of structured output to request. [RequiresDynamicCode("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. " + "Use System.Text.Json source generation for native AOT applications.")] + [RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. " + + "Use System.Text.Json source generation for native AOT applications.")] public static Task> CompleteAsync( this IChatClient chatClient, string chatMessage, diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 0fff0cd64fa..c562db8ca3a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -44,6 +44,8 @@ class AIFunctionFactory /// The method to be represented via the created . /// Metadata to use to override defaults inferred from . /// The created for invoking . + [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] + [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions options) { _ = Throw.IfNull(method); @@ -67,6 +69,8 @@ public static AIFunction Create(Delegate method, string? name, string? descripti /// The name to use for the . /// The description to use for the . /// The created for invoking . + [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] + [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] public static AIFunction Create(Delegate method, JsonSerializerOptions options, string? name = null, string? description = null) { _ = Throw.IfNull(method); @@ -100,6 +104,8 @@ public static AIFunction Create(MethodInfo method, object? target = null) /// /// Metadata to use to override defaults inferred from . /// The created for invoking . + [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] + [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] public static AIFunction Create(MethodInfo method, object? target, AIFunctionFactoryCreateOptions options) { _ = Throw.IfNull(method); @@ -130,6 +136,8 @@ class ReflectionAIFunction : AIFunction /// This should be if and only if is a static method. /// /// Function creation options. + [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] + [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] public ReflectionAIFunction(MethodInfo method, object? target, AIFunctionFactoryCreateOptions options) { _ = Throw.IfNull(method); @@ -376,6 +384,8 @@ static bool IsAsyncMethod(MethodInfo method) /// /// Gets a delegate for handling the result value of a method, converting it into the to return from the invocation. /// + [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] + [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] private static Type GetReturnMarshaler(MethodInfo method, out Func> marshaler) { // Handle each known return type for the method diff --git a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs b/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs index 71edc9404b6..06317f570a2 100644 --- a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs @@ -28,9 +28,9 @@ private static JsonSerializerOptions CreateDefaultOptions() var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, -#pragma warning disable IL3050 +#pragma warning disable IL3050, IL2026 // only used when reflection-based serialization is enabled TypeInfoResolver = new DefaultJsonTypeInfoResolver(), -#pragma warning restore IL3050 +#pragma warning restore IL3050, IL2026 }; options.MakeReadOnly(); diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 8e389b61652..39b33458d0c 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -18,6 +18,11 @@ true + + + $(NoWarn);IL2026 + + true true From 331ddb5bb5e69e812cd37164b4f9cbf25b3068cf Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 8 Oct 2024 16:55:12 -0400 Subject: [PATCH 010/190] Improve registration of IDistributedCache in READMEs (#5480) --- .../Microsoft.Extensions.AI.Abstractions/README.md | 7 +------ .../Microsoft.Extensions.AI.AzureAIInference/README.md | 5 +---- src/Libraries/Microsoft.Extensions.AI.Ollama/README.md | 5 +---- src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md | 5 +---- 4 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md index eb9d3a28c6f..4cacbda0a4f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md @@ -317,17 +317,12 @@ await client.CompleteAsync("Hello, world!"); ```csharp using Microsoft.Extensions.AI; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; -using System.Runtime.CompilerServices; // App Setup var builder = Host.CreateApplicationBuilder(); -builder.Services.AddSingleton( - new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))); +builder.Services.AddDistributedMemoryCache(); builder.Services.AddChatClient(b => b .UseDistributedCache() .Use(new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model"))); diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md index 3fd34c7897b..f34e89a08fb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md @@ -223,12 +223,9 @@ static int GetPersonAge(string personName) => using Azure; using Azure.AI.Inference; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; // App Setup var builder = Host.CreateApplicationBuilder(); @@ -236,7 +233,7 @@ builder.Services.AddSingleton( new ChatCompletionsClient( new("https://models.inference.ai.azure.com"), new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!))); -builder.Services.AddSingleton(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))); +builder.Services.AddDistributedMemoryCache(); builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); builder.Services.AddChatClient(b => b diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md index ef8c60ff7b2..3d2eddcafc1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md @@ -226,16 +226,13 @@ foreach (var prompt in new[] { "What is AI?", "What is .NET?", "What is AI?" }) ```csharp using Microsoft.Extensions.AI; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; // App Setup var builder = Host.CreateApplicationBuilder(); -builder.Services.AddSingleton(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))); +builder.Services.AddDistributedMemoryCache(); builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); builder.Services.AddChatClient(b => b diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md index f7af212f4d7..696cc0c01bf 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md @@ -249,18 +249,15 @@ foreach (var prompt in new[] { "What is AI?", "What is .NET?", "What is AI?" }) ```csharp using Microsoft.Extensions.AI; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using OpenAI; // App Setup var builder = Host.CreateApplicationBuilder(); builder.Services.AddSingleton(new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY"))); -builder.Services.AddSingleton(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))); +builder.Services.AddDistributedMemoryCache(); builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); builder.Services.AddChatClient(b => b From 0c8bc3ea381bb44c00daec2d7f02c4e0413962ec Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 9 Oct 2024 06:54:43 -0400 Subject: [PATCH 011/190] Flip default on FunctionInvokingChatClient.ConcurrentInvocation (#5485) * Flip default on FunctionInvokingChatClient.ConcurrentInvocation For better reliability, default ConcurrentInvocation to false, so that it doesn't introduce concurrency / parallelism where there wasn't any. * Update src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs Co-authored-by: Igor Velikorossov --------- Co-authored-by: Igor Velikorossov --- .../FunctionInvokingChatClient.cs | 9 ++++---- .../FunctionInvokingChatClientTests.cs | 21 +++++++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index c46d7f43156..16e9d62f25b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -82,15 +82,14 @@ public FunctionInvokingChatClient(IChatClient innerClient) /// /// /// An individual response from the inner client may contain multiple function call requests. - /// By default, such function calls may be issued to execute concurrently with each other. Set - /// to false to disable such concurrent invocation and force - /// the functions to be invoked serially. + /// By default, such function calls are processed serially. Set to + /// to enable concurrent invocation such that multiple function calls may execute in parallel. /// /// - /// The default value is . + /// The default value is . /// /// - public bool ConcurrentInvocation { get; set; } = true; + public bool ConcurrentInvocation { get; set; } /// /// Gets or sets a value indicating whether to keep intermediate messages in the chat history. diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 8ad0c6d7944..20780d968f7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -12,6 +12,19 @@ namespace Microsoft.Extensions.AI; public class FunctionInvokingChatClientTests { + [Fact] + public void Ctor_HasExpectedDefaults() + { + using TestChatClient innerClient = new(); + using FunctionInvokingChatClient client = new(innerClient); + + Assert.False(client.ConcurrentInvocation); + Assert.False(client.DetailedErrors); + Assert.True(client.KeepFunctionCallingMessages); + Assert.Null(client.MaximumIterationsPerRequest); + Assert.False(client.RetryOnError); + } + [Fact] public async Task SupportsSingleFunctionCallPerRequestAsync() { @@ -71,7 +84,7 @@ await InvokeAndAssertAsync(options, [ } [Fact] - public async Task ParallelFunctionCallsInvokedConcurrentlyByDefaultAsync() + public async Task ParallelFunctionCallsMayBeInvokedConcurrentlyAsync() { using var barrier = new Barrier(2); @@ -97,11 +110,11 @@ await InvokeAndAssertAsync(options, [ new FunctionResultContent("callId2", "Func", result: "worldworld"), ]), new ChatMessage(ChatRole.Assistant, "done"), - ]); + ], configurePipeline: b => b.Use(s => new FunctionInvokingChatClient(s) { ConcurrentInvocation = true })); } [Fact] - public async Task ConcurrentInvocationOfParallelCallsCanBeDisabledAsync() + public async Task ConcurrentInvocationOfParallelCallsDisabledByDefaultAsync() { int activeCount = 0; @@ -130,7 +143,7 @@ await InvokeAndAssertAsync(options, [ new FunctionResultContent("callId2", "Func", result: "worldworld"), ]), new ChatMessage(ChatRole.Assistant, "done"), - ], configurePipeline: b => b.Use(s => new FunctionInvokingChatClient(s) { ConcurrentInvocation = false })); + ]); } [Theory] From b2d0dfedb31264d89a15bb4fe7cda159023caa5f Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 9 Oct 2024 14:51:19 +0100 Subject: [PATCH 012/190] Remove x64 hardcoding from global.json runtimes --- global.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/global.json b/global.json index f95788c5017..c958c2fa036 100644 --- a/global.json +++ b/global.json @@ -5,11 +5,11 @@ "tools": { "dotnet": "9.0.100-rtm.24479.2", "runtimes": { - "dotnet/x64": [ + "dotnet": [ "8.0.0", "9.0.0-rc.1.24431.7" ], - "aspnetcore/x64": [ + "aspnetcore": [ "8.0.0", "9.0.0-rc.1.24452.1" ] From a51631ed08bd6e644406661e5c4d4bc6c2d86cb6 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 9 Oct 2024 20:08:15 +0100 Subject: [PATCH 013/190] Fix AIFunctionFactory support for AOT. (#5494) --- .../Functions/AIFunctionFactory.cs | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index c562db8ca3a..a3bd73c602a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -44,8 +44,6 @@ class AIFunctionFactory /// The method to be represented via the created . /// Metadata to use to override defaults inferred from . /// The created for invoking . - [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] - [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions options) { _ = Throw.IfNull(method); @@ -69,8 +67,6 @@ public static AIFunction Create(Delegate method, string? name, string? descripti /// The name to use for the . /// The description to use for the . /// The created for invoking . - [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] - [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] public static AIFunction Create(Delegate method, JsonSerializerOptions options, string? name = null, string? description = null) { _ = Throw.IfNull(method); @@ -104,8 +100,6 @@ public static AIFunction Create(MethodInfo method, object? target = null) /// /// Metadata to use to override defaults inferred from . /// The created for invoking . - [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] - [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] public static AIFunction Create(MethodInfo method, object? target, AIFunctionFactoryCreateOptions options) { _ = Throw.IfNull(method); @@ -136,8 +130,6 @@ class ReflectionAIFunction : AIFunction /// This should be if and only if is a static method. /// /// Function creation options. - [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] - [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] public ReflectionAIFunction(MethodInfo method, object? target, AIFunctionFactoryCreateOptions options) { _ = Throw.IfNull(method); @@ -384,8 +376,6 @@ static bool IsAsyncMethod(MethodInfo method) /// /// Gets a delegate for handling the result value of a method, converting it into the to return from the invocation. /// - [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] - [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] private static Type GetReturnMarshaler(MethodInfo method, out Func> marshaler) { // Handle each known return type for the method @@ -416,9 +406,9 @@ private static Type GetReturnMarshaler(MethodInfo method, out Func - if (returnType.GetGenericTypeDefinition() == typeof(Task<>) && - returnType.GetProperty(nameof(Task.Result), BindingFlags.Public | BindingFlags.Instance)?.GetGetMethod() is MethodInfo taskResultGetter) + if (returnType.GetGenericTypeDefinition() == typeof(Task<>)) { + MethodInfo taskResultGetter = GetMethodFromGenericMethodDefinition(returnType, _taskGetResult); marshaler = async result => { await ((Task)ThrowIfNullResult(result)).ConfigureAwait(false); @@ -428,10 +418,10 @@ private static Type GetReturnMarshaler(MethodInfo method, out Func - if (returnType.GetGenericTypeDefinition() == typeof(ValueTask<>) && - returnType.GetMethod(nameof(ValueTask.AsTask), BindingFlags.Public | BindingFlags.Instance) is MethodInfo valueTaskAsTask && - valueTaskAsTask.ReturnType.GetProperty(nameof(ValueTask.Result), BindingFlags.Public | BindingFlags.Instance)?.GetGetMethod() is MethodInfo asTaskResultGetter) + if (returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) { + MethodInfo valueTaskAsTask = GetMethodFromGenericMethodDefinition(returnType, _valueTaskAsTask); + MethodInfo asTaskResultGetter = GetMethodFromGenericMethodDefinition(valueTaskAsTask.ReturnType, _taskGetResult); marshaler = async result => { var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(result), null)!; @@ -471,6 +461,20 @@ private static Type GetReturnMarshaler(MethodInfo method, out Func).GetProperty(nameof(Task.Result), BindingFlags.Instance | BindingFlags.Public)!.GetMethod!; + private static readonly MethodInfo _valueTaskAsTask = typeof(ValueTask<>).GetMethod(nameof(ValueTask.AsTask), BindingFlags.Instance | BindingFlags.Public)!; + + [UnconditionalSuppressMessage("Trimming", "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", + Justification = "The MethodInfo we are looking for must have already been rooted by virtue of its generic definition being available.")] + private static MethodInfo GetMethodFromGenericMethodDefinition(Type specializedType, MethodInfo genericMethodDefinition) + { + Debug.Assert(specializedType.IsGenericType && specializedType.GetGenericTypeDefinition() == genericMethodDefinition.DeclaringType, "generic member definition doesn't match type."); +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; +#pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken); + } + /// /// Remove characters from method name that are valid in metadata but shouldn't be used in a method name. /// This is primarily intended to remove characters emitted by for compiler-generated method name mangling. From 397010983e853005c7cd96b1865baac147d755f9 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 9 Oct 2024 16:13:05 -0400 Subject: [PATCH 014/190] Mark the FunctionCall/ResultContent.Exception properties as [JsonIgnore] (#5492) Given implementation details of the JSON source generator today, even with the converter applied to these properties, code is still being generated for Exception, leading to unsuppressable trimmer warnings. --- .../Contents/FunctionCallContent.cs | 8 +- .../FunctionCallExceptionConverter.cs | 96 ------------------- .../Contents/FunctionResultContent.cs | 48 ++++++++-- .../Microsoft.Extensions.AI/JsonDefaults.cs | 5 +- .../Microsoft.Extensions.AI.csproj | 5 - .../Contents/FunctionCallContentTests..cs | 37 ++----- .../Contents/FunctionResultContentTests.cs | 12 ++- 7 files changed, 59 insertions(+), 152 deletions(-) delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallExceptionConverter.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index 7eefdd90a09..b50fc531179 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -49,11 +49,11 @@ public FunctionCallContent(string callId, string name, IDictionary /// - /// When an instance of is serialized using , any exception - /// stored in this property will be serialized as a string. When deserialized, the string will be converted back to an instance - /// of the base type. As such, consumers shouldn't rely on the exact type of the exception stored in this property. + /// This property is for information purposes only. The is not serialized as part of serializing + /// instances of this class with ; as such, upon deserialization, this property will be . + /// Consumers should not rely on indicating success. /// - [JsonConverter(typeof(FunctionCallExceptionConverter))] + [JsonIgnore] public Exception? Exception { get; set; } /// Gets a string representing this instance to display in the debugger. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallExceptionConverter.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallExceptionConverter.cs deleted file mode 100644 index 0c36f11ca40..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallExceptionConverter.cs +++ /dev/null @@ -1,96 +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; -#if NET -using System.Runtime.ExceptionServices; -#endif -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// Serializes an exception as a string and deserializes it back as a base containing that contents as a message. -[EditorBrowsable(EditorBrowsableState.Never)] -public sealed class FunctionCallExceptionConverter : JsonConverter -{ - private const string ClassNamePropertyName = "className"; - private const string MessagePropertyName = "message"; - private const string InnerExceptionPropertyName = "innerException"; - private const string StackTracePropertyName = "stackTraceString"; - - /// - public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options) - { - _ = Throw.IfNull(writer); - _ = Throw.IfNull(value); - - // Schema and property order taken from Exception.GetObjectData() implementation. - - writer.WriteStartObject(); - writer.WriteString(ClassNamePropertyName, value.GetType().ToString()); - writer.WriteString(MessagePropertyName, value.Message); - writer.WritePropertyName(InnerExceptionPropertyName); - if (value.InnerException is Exception innerEx) - { - Write(writer, innerEx, options); - } - else - { - writer.WriteNullValue(); - } - - writer.WriteString(StackTracePropertyName, value.StackTrace); - writer.WriteEndObject(); - } - - /// - public override Exception? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.StartObject) - { - throw new JsonException(); - } - - using var doc = JsonDocument.ParseValue(ref reader); - return ParseExceptionCore(doc.RootElement); - - static Exception ParseExceptionCore(JsonElement element) - { - string? message = null; - string? stackTrace = null; - Exception? innerEx = null; - - foreach (JsonProperty property in element.EnumerateObject()) - { - switch (property.Name) - { - case MessagePropertyName: - message = property.Value.GetString(); - break; - - case StackTracePropertyName: - stackTrace = property.Value.GetString(); - break; - - case InnerExceptionPropertyName when property.Value.ValueKind is not JsonValueKind.Null: - innerEx = ParseExceptionCore(property.Value); - break; - } - } - -#pragma warning disable CA2201 // Do not raise reserved exception types - Exception result = new(message, innerEx); -#pragma warning restore CA2201 -#if NET - if (stackTrace != null) - { - ExceptionDispatchInfo.SetRemoteStackTrace(result, stackTrace); - } -#endif - return result; - } - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs index 0a416d64f5f..f793e2ceceb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs @@ -20,10 +20,31 @@ public sealed class FunctionResultContent : AIContent /// /// The function call ID for which this is the result. /// The function name that produced the result. - /// The function call result. - /// Any exception that occurred when invoking the function. + /// + /// This may be if the function returned , if the function was void-returning + /// and thus had no result, or if the function call failed. Typically, however, in order to provide meaningfully representative + /// information to an AI service, a human-readable representation of those conditions should be supplied. + /// [JsonConstructor] - public FunctionResultContent(string callId, string name, object? result = null, Exception? exception = null) + public FunctionResultContent(string callId, string name, object? result) + { + CallId = Throw.IfNull(callId); + Name = Throw.IfNull(name); + Result = result; + } + + /// + /// Initializes a new instance of the class. + /// + /// The function call ID for which this is the result. + /// The function name that produced the result. + /// + /// This may be if the function returned , if the function was void-returning + /// and thus had no result, or if the function call failed. Typically, however, in order to provide meaningfully representative + /// information to an AI service, a human-readable representation of those conditions should be supplied. + /// + /// Any exception that occurred when invoking the function. + public FunctionResultContent(string callId, string name, object? result, Exception? exception) { CallId = Throw.IfNull(callId); Name = Throw.IfNull(name); @@ -35,9 +56,13 @@ public FunctionResultContent(string callId, string name, object? result = null, /// Initializes a new instance of the class. /// /// The function call for which this is the result. - /// The function call result. + /// + /// This may be if the function returned , if the function was void-returning + /// and thus had no result, or if the function call failed. Typically, however, in order to provide meaningfully representative + /// information to an AI service, a human-readable representation of those conditions should be supplied. + /// /// Any exception that occurred when invoking the function. - public FunctionResultContent(FunctionCallContent functionCall, object? result = null, Exception? exception = null) + public FunctionResultContent(FunctionCallContent functionCall, object? result, Exception? exception = null) : this(Throw.IfNull(functionCall).CallId, functionCall.Name, result, exception) { } @@ -59,17 +84,22 @@ public FunctionResultContent(FunctionCallContent functionCall, object? result = /// /// Gets or sets the result of the function call, or a generic error message if the function call failed. /// + /// + /// This may be if the function returned , if the function was void-returning + /// and thus had no result, or if the function call failed. Typically, however, in order to provide meaningfully representative + /// information to an AI service, a human-readable representation of those conditions should be supplied. + /// public object? Result { get; set; } /// /// Gets or sets an exception that occurred if the function call failed. /// /// - /// When an instance of is serialized using , any exception - /// stored in this property will be serialized as a string. When deserialized, the string will be converted back to an instance - /// of the base type. As such, consumers shouldn't rely on the exact type of the exception stored in this property. + /// This property is for information purposes only. The is not serialized as part of serializing + /// instances of this class with ; as such, upon deserialization, this property will be . + /// Consumers should not rely on indicating success. /// - [JsonConverter(typeof(FunctionCallExceptionConverter))] + [JsonIgnore] public Exception? Exception { get; set; } /// Gets a string representing this instance to display in the debugger. diff --git a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs b/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs index 06317f570a2..467d6eb3feb 100644 --- a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -16,6 +17,8 @@ internal static partial class JsonDefaults public static JsonSerializerOptions Options { get; } = CreateDefaultOptions(); /// Creates the default to use for serialization-related operations. + [UnconditionalSuppressMessage("AotAnalysis", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] private static JsonSerializerOptions CreateDefaultOptions() { // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, @@ -28,9 +31,7 @@ private static JsonSerializerOptions CreateDefaultOptions() var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, -#pragma warning disable IL3050, IL2026 // only used when reflection-based serialization is enabled TypeInfoResolver = new DefaultJsonTypeInfoResolver(), -#pragma warning restore IL3050, IL2026 }; options.MakeReadOnly(); diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 39b33458d0c..8e389b61652 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -18,11 +18,6 @@ true - - - $(NoWarn);IL2026 - - true true diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs index 791bb4cc0e7..054b0eeefec 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs @@ -5,9 +5,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -#if NET -using System.Runtime.ExceptionServices; -#endif using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; @@ -89,41 +86,19 @@ public void ItShouldBeSerializableAndDeserializableWithException() { // Arrange var ex = new InvalidOperationException("hello", new NullReferenceException("bye")); -#if NET - ExceptionDispatchInfo.SetRemoteStackTrace(ex, "stack trace"); -#endif - var sut = new FunctionCallContent("callId1", "functionName") { Exception = ex }; + var sut = new FunctionCallContent("callId1", "functionName", new Dictionary { ["key"] = "value" }) { Exception = ex }; // Act var json = JsonSerializer.SerializeToNode(sut, TestJsonSerializerContext.Default.Options); var deserializedSut = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); // Assert - JsonObject jsonEx = Assert.IsType(json!["exception"]); - Assert.Equal(4, jsonEx.Count); - Assert.Equal("System.InvalidOperationException", (string?)jsonEx["className"]); - Assert.Equal("hello", (string?)jsonEx["message"]); -#if NET - Assert.StartsWith("stack trace", (string?)jsonEx["stackTraceString"]); -#endif - JsonObject jsonExInner = Assert.IsType(jsonEx["innerException"]); - Assert.Equal(4, jsonExInner.Count); - Assert.Equal("System.NullReferenceException", (string?)jsonExInner["className"]); - Assert.Equal("bye", (string?)jsonExInner["message"]); - Assert.Null(jsonExInner["innerException"]); - Assert.Null(jsonExInner["stackTraceString"]); - Assert.NotNull(deserializedSut); - Assert.IsType(deserializedSut.Exception); - Assert.Equal("hello", deserializedSut.Exception.Message); -#if NET - Assert.StartsWith("stack trace", deserializedSut.Exception.StackTrace); -#endif - - Assert.IsType(deserializedSut.Exception.InnerException); - Assert.Equal("bye", deserializedSut.Exception.InnerException.Message); - Assert.Null(deserializedSut.Exception.InnerException.StackTrace); - Assert.Null(deserializedSut.Exception.InnerException.InnerException); + Assert.Equal("callId1", deserializedSut.CallId); + Assert.Equal("functionName", deserializedSut.Name); + Assert.NotNull(deserializedSut.Arguments); + Assert.Single(deserializedSut.Arguments); + Assert.Null(deserializedSut.Exception); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs index a24120ca9a9..a70386e42c6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs @@ -12,7 +12,7 @@ public class FunctionResultContentTests [Fact] public void Constructor_PropsDefault() { - FunctionResultContent c = new("callId1", "functionName"); + FunctionResultContent c = new("callId1", "functionName", null); Assert.Equal("callId1", c.CallId); Assert.Equal("functionName", c.Name); Assert.Null(c.RawRepresentation); @@ -54,7 +54,7 @@ public void Constructor_FunctionCallContent_PropsRoundtrip() [Fact] public void Constructor_PropsRoundtrip() { - FunctionResultContent c = new("callId1", "functionName"); + FunctionResultContent c = new("callId1", "functionName", null); Assert.Null(c.RawRepresentation); object raw = new(); @@ -106,7 +106,7 @@ public void ItShouldBeSerializableAndDeserializable() public void ItShouldBeSerializableAndDeserializableWithException() { // Arrange - var sut = new FunctionResultContent("callId1", "functionName") { Exception = new InvalidOperationException("hello") }; + var sut = new FunctionResultContent("callId1", "functionName", null, new InvalidOperationException("hello")); // Act var json = JsonSerializer.Serialize(sut, TestJsonSerializerContext.Default.Options); @@ -114,7 +114,9 @@ public void ItShouldBeSerializableAndDeserializableWithException() // Assert Assert.NotNull(deserializedSut); - Assert.IsType(deserializedSut.Exception); - Assert.Contains("hello", deserializedSut.Exception.Message); + Assert.Equal(sut.Name, deserializedSut.Name); + Assert.Equal(sut.CallId, deserializedSut.CallId); + Assert.Equal(sut.Result, deserializedSut.Result?.ToString()); + Assert.Null(deserializedSut.Exception); } } From 7ada97e388d756b8fec441df75da6aa373181907 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Thu, 10 Oct 2024 10:06:11 +1100 Subject: [PATCH 015/190] First cut of code ownership (#5486) Contributes to #4656 --- .github/CODEOWNERS | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..d6517452658 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,28 @@ +# These owners will be the default owners for everything in the repo. Unless a later match takes precedence, +# @dotnet/dotnet-extensions-fundamentals will be requested for review when someone opens a pull request. + +*.cmd @dotnet/dotnet-extensions-infra +*.sh @dotnet/dotnet-extensions-infra +*.ps1 @dotnet/dotnet-extensions-infra +*.yml @dotnet/dotnet-extensions-infra +*.props @dotnet/dotnet-extensions-infra +*.targets @dotnet/dotnet-extensions-infra +/global.json @dotnet/dotnet-extensions-infra +/.azure/ @dotnet/dotnet-extensions-infra +/.azuredevops/ @dotnet/dotnet-extensions-infra +/.config/ @dotnet/dotnet-extensions-infra +/.devcontainer/ @dotnet/dotnet-extensions-infra +/.vscode/ @dotnet/dotnet-extensions-infra +/.github/ @dotnet/dotnet-extensions-infra +/docs/ @dotnet/dotnet-extensions-infra +/eng/ @dotnet/dotnet-extensions-infra + +/src/Libraries/Microsoft.Extensions.AI @dotnet/dotnet-extensions-ai +/src/Libraries/Microsoft.Extensions.AI.* @dotnet/dotnet-extensions-ai +/test/Libraries/Microsoft.Extensions.AI @dotnet/dotnet-extensions-ai +/test/Libraries/Microsoft.Extensions.AI.* @dotnet/dotnet-extensions-ai + +/src/Libraries/Microsoft.Extensions.Caching.Hybrid @dotnet/dotnet-extensions-caching-hybrid +/src/Libraries/Microsoft.Extensions.Caching.Hybrid.* @dotnet/dotnet-extensions-caching-hybrid +/test/Libraries/Microsoft.Extensions.Caching.Hybrid @dotnet/dotnet-extensions-caching-hybrid +/test/Libraries/Microsoft.Extensions.Caching.Hybrid.* @dotnet/dotnet-extensions-caching-hybrid From 1b64e766516616ad7dac8fac6a84484fce9f5ff4 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 9 Oct 2024 21:30:05 -0400 Subject: [PATCH 016/190] Set WriteIndented=true for M.E.AI logging / telemetry / etc. --- src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs b/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs index 467d6eb3feb..f7aabcff6fd 100644 --- a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs @@ -30,6 +30,7 @@ private static JsonSerializerOptions CreateDefaultOptions() // Keep in sync with the JsonSourceGenerationOptions on JsonContext below. var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { + WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, TypeInfoResolver = new DefaultJsonTypeInfoResolver(), }; @@ -44,7 +45,7 @@ private static JsonSerializerOptions CreateDefaultOptions() } // Keep in sync with CreateDefaultOptions above. - [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(IList))] [JsonSerializable(typeof(ChatOptions))] [JsonSerializable(typeof(EmbeddingGenerationOptions))] From defa6c1d234fd6f57cfae2eb80a0d31c49e7b016 Mon Sep 17 00:00:00 2001 From: Genevieve Warren <24882762+gewarren@users.noreply.github.com> Date: Wed, 9 Oct 2024 23:26:54 -0700 Subject: [PATCH 017/190] Doc updates to HybridCacheOptions (#5493) * doc updates * Update src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs --------- Co-authored-by: Igor Velikorossov --- .../HybridCacheOptions.cs | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs index 982ea55a6af..473f1e3c46d 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs @@ -11,11 +11,13 @@ public class HybridCacheOptions private const int ShiftBytesToMibiBytes = 20; /// - /// Gets or sets the default global options to be applied to operations; if options are - /// specified at the individual call level, the non-null values are merged (with the per-call - /// options being used in preference to the global options). If no value is specified for a given - /// option (globally or per-call), the implementation may choose a reasonable default. + /// Gets or sets the default global options to be applied to operations. /// + /// + /// If options are specified at the individual call level, the non-null values are merged + /// (with the per-call options being used in preference to the global options). If no value is + /// specified for a given option (globally or per-call), the implementation can choose a reasonable default. + /// public HybridCacheEntryOptions? DefaultEntryOptions { get; set; } /// @@ -24,21 +26,35 @@ public class HybridCacheOptions public bool DisableCompression { get; set; } /// - /// Gets or sets the maximum size of cache items; attempts to store values over this size will be logged - /// and the value will not be stored in cache. + /// Gets or sets the maximum size of cache items. /// - /// The default value is 1 MiB. + /// + /// The maximum size of cache items. The default value is 1 MiB. + /// + /// + /// Attempts to store values over this size are logged, + /// and the value isn't stored in the cache. + /// public long MaximumPayloadBytes { get; set; } = 1 << ShiftBytesToMibiBytes; // 1MiB /// - /// Gets or sets the maximum permitted length (in characters) of keys; attempts to use keys over this size will be logged. + /// Gets or sets the maximum permitted length (in characters) of keys. /// - /// The default value is 1024 characters. + /// + /// The maximum permitted length of keys, in characters. The default value is 1024 characters. + /// + /// Attempts to use keys over this size are logged. public int MaximumKeyLength { get; set; } = 1024; // characters /// - /// Gets or sets a value indicating whether to use "tags" data as dimensions on metric reporting; if enabled, care should be used to ensure that - /// tags do not contain data that should not be visible in metrics systems. + /// Gets or sets a value indicating whether to use "tags" data as dimensions on metric reporting. /// + /// + /// to use "tags" data as dimensions on metric reporting; otherwise, . + /// + /// + /// If enabled, take care to ensure that tags don't contain data that + /// should not be visible in metrics systems. + /// public bool ReportTagMetrics { get; set; } } From c8472c77ec014e7c8d57af0de6dbd20427d9500d Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Thu, 10 Oct 2024 10:54:35 -0300 Subject: [PATCH 018/190] Sanitize structured output schema type name to satisfy restrictions The underlying native schema support in OpenAI has specific requirements on valid schema names, as shown in the following exception when using either an array or any other generic type: ``` Unhandled exception. System.ClientModel.ClientResultException: HTTP 400 (invalid_request_error: invalid_value) Parameter: response_format.json_schema.name Invalid 'response_format.json_schema.name': string does not match pattern. Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'. at OpenAI.ClientPipelineExtensions.ProcessMessageAsync(ClientPipeline pipeline, PipelineMessage message, RequestOptions options) ``` This fix follows the approach used to sanitize function names, and sanitizes the schema name the same way. Fixes #5501 --- .../ChatClientStructuredOutputExtensions.cs | 20 +++++++++- ...atClientStructuredOutputExtensionsTests.cs | 39 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index 5d16440a8fa..fca27f04ce2 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -11,6 +11,7 @@ using System.Text.Json.Schema; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -167,7 +168,7 @@ public static async Task> CompleteAsync( // the LLM backend is meant to do whatever's needed to explain the schema to the LLM. options.ResponseFormat = ChatResponseFormat.ForJsonSchema( schema, - schemaName: typeof(T).Name, + schemaName: SanitizeMetadataName(typeof(T).Name), schemaDescription: typeof(T).GetCustomAttribute()?.Description); } else @@ -224,4 +225,21 @@ private static JsonSerializerOptions GetOrCreateDefaultJsonSerializerOptions() [JsonSerializable(typeof(JsonNode))] [JsonSourceGenerationOptions(WriteIndented = true)] private sealed partial class JsonNodeContext : JsonSerializerContext; + + /// + /// Remove characters from type name that are valid in metadata but shouldn't be used in a schema name. + /// Removes arrays and generic type parameters, and replaces invalid characters with underscores. + /// + private static string SanitizeMetadataName(string typeName) => + InvalidNameCharsRegex().Replace(typeName, "_"); + + /// Regex that flags any character other than ASCII digits or letters or the underscore. +#if NET + [GeneratedRegex("[^0-9A-Za-z_]")] + private static partial Regex InvalidNameCharsRegex(); +#else + private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; + private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); +#endif + } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index 0e776b4fee5..3c02b86055f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -172,6 +172,40 @@ public async Task CanUseNativeStructuredOutput() Assert.Equal("Hello", Assert.Single(chatHistory).Text); } + [Fact] + public async Task CanUseNativeStructuredOutputWithSanitizedTypeName() + { + var expectedResult = new Data { Value = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger } }; + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))]); + + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => + { + var responseFormat = Assert.IsType(options!.ResponseFormat); + + Assert.Matches("^[a-zA-Z0-9_-]+$", responseFormat.SchemaName); + + return Task.FromResult(expectedCompletion); + }, + }; + + var chatHistory = new List { new(ChatRole.User, "Hello") }; + var response = await client.CompleteAsync>(chatHistory, useNativeJsonSchema: true); + + // The completion contains the deserialized result and other completion properties + Assert.Equal(1, response.Result!.Value!.Id); + Assert.Equal("Tigger", response.Result.Value.FullName); + Assert.Equal(Species.Tiger, response.Result.Value.Species); + + // TryGetResult returns the same value + Assert.True(response.TryGetResult(out var tryGetResultOutput)); + Assert.Same(response.Result, tryGetResultOutput); + + // History remains unmutated + Assert.Equal("Hello", Assert.Single(chatHistory).Text); + } + [Fact] public async Task CanSpecifyCustomJsonSerializationOptions() { @@ -247,6 +281,11 @@ private class Animal public Species Species { get; set; } } + private class Data + { + public T? Value { get; set; } + } + private enum Species { Bear, From 3d9b7f2cd9ea79a8d13867b7fd33d31ee1a351f9 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Thu, 10 Oct 2024 12:41:51 -0300 Subject: [PATCH 019/190] Move SanitizeMetadataName to existing FunctionCallHelpers --- .../Contents/FunctionCallHelpers.cs | 17 +++++++++++++++++ .../ChatClientStructuredOutputExtensions.cs | 19 +------------------ .../Functions/AIFunctionFactory.cs | 18 +----------------- 3 files changed, 19 insertions(+), 35 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs index 42eb486f4c1..e9524b91ab1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs @@ -14,6 +14,7 @@ using System.Text.Json.Nodes; using System.Text.Json.Schema; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using Microsoft.Shared.Diagnostics; using FunctionParameterKey = (System.Type? Type, string ParameterName, string? Description, bool HasDefaultValue, object? DefaultValue); @@ -375,4 +376,20 @@ private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(JsonDocument))] private sealed partial class FunctionCallHelperContext : JsonSerializerContext; + + /// + /// Remove characters from method name that are valid in metadata but shouldn't be used in a method name. + /// This is primarily intended to remove characters emitted by for compiler-generated method name mangling. + /// + public static string SanitizeMetadataName(string metadataName) => + InvalidNameCharsRegex().Replace(metadataName, "_"); + + /// Regex that flags any character other than ASCII digits or letters or the underscore. +#if NET + [GeneratedRegex("[^0-9A-Za-z_]")] + private static partial Regex InvalidNameCharsRegex(); +#else + private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; + private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); +#endif } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index fca27f04ce2..84effb1737b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -11,10 +11,10 @@ using System.Text.Json.Schema; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; +using static Microsoft.Extensions.AI.FunctionCallHelpers; namespace Microsoft.Extensions.AI; @@ -225,21 +225,4 @@ private static JsonSerializerOptions GetOrCreateDefaultJsonSerializerOptions() [JsonSerializable(typeof(JsonNode))] [JsonSourceGenerationOptions(WriteIndented = true)] private sealed partial class JsonNodeContext : JsonSerializerContext; - - /// - /// Remove characters from type name that are valid in metadata but shouldn't be used in a schema name. - /// Removes arrays and generic type parameters, and replaces invalid characters with underscores. - /// - private static string SanitizeMetadataName(string typeName) => - InvalidNameCharsRegex().Replace(typeName, "_"); - - /// Regex that flags any character other than ASCII digits or letters or the underscore. -#if NET - [GeneratedRegex("[^0-9A-Za-z_]")] - private static partial Regex InvalidNameCharsRegex(); -#else - private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; - private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); -#endif - } diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index a3bd73c602a..d0b4f0d5cc0 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -12,11 +12,11 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Collections; using Microsoft.Shared.Diagnostics; +using static Microsoft.Extensions.AI.FunctionCallHelpers; namespace Microsoft.Extensions.AI; @@ -474,21 +474,5 @@ private static MethodInfo GetMethodFromGenericMethodDefinition(Type specializedT #pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken); } - - /// - /// Remove characters from method name that are valid in metadata but shouldn't be used in a method name. - /// This is primarily intended to remove characters emitted by for compiler-generated method name mangling. - /// - private static string SanitizeMetadataName(string methodName) => - InvalidNameCharsRegex().Replace(methodName, "_"); - - /// Regex that flags any character other than ASCII digits or letters or the underscore. -#if NET - [GeneratedRegex("[^0-9A-Za-z_]")] - private static partial Regex InvalidNameCharsRegex(); -#else - private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; - private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); -#endif } } From 8dd475f4b7e68337f9fd34ee009a3c04f67fc2e7 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Thu, 10 Oct 2024 12:44:32 -0300 Subject: [PATCH 020/190] Fix S2333 'partial' is gratuitous in this context issues Removed partial due error S2333 being shown otherwise. --- .../Functions/AIFunctionFactory.cs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index d0b4f0d5cc0..84ce1fa15a0 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -21,11 +21,7 @@ namespace Microsoft.Extensions.AI; /// Provides factory methods for creating commonly-used implementations of . -public static -#if NET - partial -#endif - class AIFunctionFactory +public static class AIFunctionFactory { internal const string UsesReflectionJsonSerializerMessage = "This method uses the reflection-based JsonSerializer which can break in trimmed or AOT applications."; @@ -107,11 +103,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac return new ReflectionAIFunction(method, target, options); } - private sealed -#if NET - partial -#endif - class ReflectionAIFunction : AIFunction + private sealed class ReflectionAIFunction : AIFunction { private readonly MethodInfo _method; private readonly object? _target; From ac55349dc2919e16f984506284a49bb5e43a0055 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Thu, 10 Oct 2024 13:44:09 -0300 Subject: [PATCH 021/190] Assert specific value expected for the sanitized generic type name --- .../ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index 3c02b86055f..eea22abfacb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -184,7 +184,7 @@ public async Task CanUseNativeStructuredOutputWithSanitizedTypeName() { var responseFormat = Assert.IsType(options!.ResponseFormat); - Assert.Matches("^[a-zA-Z0-9_-]+$", responseFormat.SchemaName); + Assert.Matches("Data_1", responseFormat.SchemaName); return Task.FromResult(expectedCompletion); }, From 38d52ca0915f4dbd1c96c616ae105f2fe275326b Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 10 Oct 2024 12:27:46 -0500 Subject: [PATCH 022/190] Remove unnecessary suppression in AIFunctionFactory --- .../Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 84ce1fa15a0..3e01fb023d3 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -456,15 +456,17 @@ private static Type GetReturnMarshaler(MethodInfo method, out Func).GetProperty(nameof(Task.Result), BindingFlags.Instance | BindingFlags.Public)!.GetMethod!; private static readonly MethodInfo _valueTaskAsTask = typeof(ValueTask<>).GetMethod(nameof(ValueTask.AsTask), BindingFlags.Instance | BindingFlags.Public)!; - [UnconditionalSuppressMessage("Trimming", "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", - Justification = "The MethodInfo we are looking for must have already been rooted by virtue of its generic definition being available.")] private static MethodInfo GetMethodFromGenericMethodDefinition(Type specializedType, MethodInfo genericMethodDefinition) { Debug.Assert(specializedType.IsGenericType && specializedType.GetGenericTypeDefinition() == genericMethodDefinition.DeclaringType, "generic member definition doesn't match type."); +#if NET + return (MethodInfo)specializedType.GetMemberWithSameMetadataDefinitionAs(genericMethodDefinition); +#else #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; #pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken); +#endif } } } From b2382cd31d4478be0c9f1da60bfbfb4cd6012fe4 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Thu, 10 Oct 2024 18:30:31 +0000 Subject: [PATCH 023/190] Update dependencies from https://github.com/dotnet/arcade build 20241009.3 (#5506) [main] Update dependencies from dotnet/arcade --- eng/Version.Details.xml | 8 ++++---- eng/common/tools.ps1 | 2 +- global.json | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index d645fcf0549..5fccc8eaa2b 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -186,13 +186,13 @@ - + https://github.com/dotnet/arcade - beb827ded6acdff8c7333dfc6583cc984a8f2620 + 05c72bb3c9b38138276a8029017f2ef905dcc7fa - + https://github.com/dotnet/arcade - beb827ded6acdff8c7333dfc6583cc984a8f2620 + 05c72bb3c9b38138276a8029017f2ef905dcc7fa diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index 9574f4eb9df..22954477a57 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -900,7 +900,7 @@ function IsWindowsPlatform() { } function Get-Darc($version) { - $darcPath = "$TempDir\darc\$(New-Guid)" + $darcPath = "$TempDir\darc\$([guid]::NewGuid())" if ($version -ne $null) { & $PSScriptRoot\darc-init.ps1 -toolpath $darcPath -darcVersion $version | Out-Host } else { diff --git a/global.json b/global.json index c958c2fa036..23bae65d43c 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.24503.2", - "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.24503.2" + "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.24509.3", + "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.24509.3" } } From b4734e2101b0ecbf3a0dfad3ed0f1bf807f4cc1c Mon Sep 17 00:00:00 2001 From: carolineRe13 <60150268+carolineRe13@users.noreply.github.com> Date: Fri, 11 Oct 2024 00:06:35 +0200 Subject: [PATCH 024/190] Allowing tags to start with _ (#5478) * Allowing namespaces to start with _ Making it possible for namespaces to start with _. This is necessary to use OT exporter as it requires namespace overrides to start with _ * Adding test that checks if a tag can start with _ --- src/Generators/Microsoft.Gen.Metrics/Parser.cs | 2 +- test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Generators/Microsoft.Gen.Metrics/Parser.cs b/src/Generators/Microsoft.Gen.Metrics/Parser.cs index b9d5697e24e..da05c6a51ec 100644 --- a/src/Generators/Microsoft.Gen.Metrics/Parser.cs +++ b/src/Generators/Microsoft.Gen.Metrics/Parser.cs @@ -22,7 +22,7 @@ internal sealed class Parser private const int MaxTagNames = 30; private static readonly Regex _regex = new("^[A-Z]+[A-za-z0-9]*$", RegexOptions.Compiled); - private static readonly Regex _regexTagNames = new("^[A-Za-z]+[A-Za-z0-9_.:-]*$", RegexOptions.Compiled); + private static readonly Regex _regexTagNames = new("^[A-Za-z_]+[A-Za-z0-9_.:-]*$", RegexOptions.Compiled); private static readonly SymbolDisplayFormat _typeSymbolFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); diff --git a/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs b/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs index 865315511f6..d6496ac10f3 100644 --- a/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs +++ b/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs @@ -144,7 +144,7 @@ public async Task ValidDimensionsKeyNames() var d = await RunGenerator(@" partial class C { - [Counter(""Env.Name"", ""clustr:region"", ""Req_Name"", ""Req-Status"")] + [Counter(""Env.Name"", ""clustr:region"", ""Req_Name"", ""Req-Status"", ""_microsoft_metrics_namespace"")] static partial TestCounter CreateMetricName(Meter meter, string env, string region); }"); From 058d8279985a7fdeb14d74a5a7a1d20e60df915b Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 11 Oct 2024 00:18:16 +0100 Subject: [PATCH 025/190] Use the same JsonSerializerOptions default in all locations. (#5507) --- .../ChatClientStructuredOutputExtensions.cs | 45 ++-------------- .../Functions/AIFunctionFactory.cs | 51 ++++++++----------- .../AIFunctionFactoryCreateOptions.cs | 5 +- .../Microsoft.Extensions.AI/JsonDefaults.cs | 13 +++-- 4 files changed, 35 insertions(+), 79 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index 84effb1737b..a320600bee2 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -1,16 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; 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 System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -21,13 +17,8 @@ namespace Microsoft.Extensions.AI; /// /// Provides extension methods on that simplify working with structured output. /// -public static partial class ChatClientStructuredOutputExtensions +public static class ChatClientStructuredOutputExtensions { - private const string UsesReflectionJsonSerializerMessage = - "This method uses the reflection-based JsonSerializer which can break in trimmed or AOT applications."; - - private static JsonSerializerOptions? _defaultJsonSerializerOptions; - /// Sends chat messages to the model, requesting a response matching the type . /// The . /// The chat content to send. @@ -44,8 +35,6 @@ public static partial class ChatClientStructuredOutputExtensions /// by the client, including any messages for roundtrips to the model as part of the implementation of this request, will be included. /// /// The type of structured output to request. - [RequiresUnreferencedCode(UsesReflectionJsonSerializerMessage)] - [RequiresDynamicCode(UsesReflectionJsonSerializerMessage)] public static Task> CompleteAsync( this IChatClient chatClient, IList chatMessages, @@ -53,7 +42,7 @@ public static Task> CompleteAsync( bool? useNativeJsonSchema = null, CancellationToken cancellationToken = default) where T : class => - CompleteAsync(chatClient, chatMessages, DefaultJsonSerializerOptions, options, useNativeJsonSchema, cancellationToken); + CompleteAsync(chatClient, chatMessages, JsonDefaults.Options, options, useNativeJsonSchema, cancellationToken); /// Sends a user chat text message to the model, requesting a response matching the type . /// The . @@ -67,10 +56,6 @@ public static Task> CompleteAsync( /// The to monitor for cancellation requests. The default is . /// The response messages generated by the client. /// The type of structured output to request. - [RequiresDynamicCode("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. " - + "Use System.Text.Json source generation for native AOT applications.")] - [RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. " - + "Use System.Text.Json source generation for native AOT applications.")] public static Task> CompleteAsync( this IChatClient chatClient, string chatMessage, @@ -154,7 +139,7 @@ public static async Task> CompleteAsync( }); schemaNode.Insert(0, "$schema", "https://json-schema.org/draft/2020-12/schema"); schemaNode.Add("additionalProperties", false); - var schema = JsonSerializer.Serialize(schemaNode, JsonNodeContext.Default.JsonNode); + var schema = JsonSerializer.Serialize(schemaNode, JsonDefaults.Options.GetTypeInfo(typeof(JsonNode))); ChatMessage? promptAugmentation = null; options = (options ?? new()).Clone(); @@ -201,28 +186,4 @@ public static async Task> CompleteAsync( } } } - - private static JsonSerializerOptions DefaultJsonSerializerOptions - { - [RequiresUnreferencedCode(UsesReflectionJsonSerializerMessage)] - [RequiresDynamicCode(UsesReflectionJsonSerializerMessage)] - get => _defaultJsonSerializerOptions ?? GetOrCreateDefaultJsonSerializerOptions(); - } - - [RequiresUnreferencedCode(UsesReflectionJsonSerializerMessage)] - [RequiresDynamicCode(UsesReflectionJsonSerializerMessage)] - private static JsonSerializerOptions GetOrCreateDefaultJsonSerializerOptions() - { - var options = new JsonSerializerOptions(JsonSerializerDefaults.General) - { - Converters = { new JsonStringEnumConverter() }, - TypeInfoResolver = new DefaultJsonTypeInfoResolver(), - WriteIndented = true, - }; - return Interlocked.CompareExchange(ref _defaultJsonSerializerOptions, options, null) ?? options; - } - - [JsonSerializable(typeof(JsonNode))] - [JsonSourceGenerationOptions(WriteIndented = true)] - private sealed partial class JsonNodeContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 3e01fb023d3..24f5a96d75d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -23,17 +23,12 @@ namespace Microsoft.Extensions.AI; /// Provides factory methods for creating commonly-used implementations of . public static class AIFunctionFactory { - internal const string UsesReflectionJsonSerializerMessage = - "This method uses the reflection-based JsonSerializer which can break in trimmed or AOT applications."; - /// Lazily-initialized default options instance. private static AIFunctionFactoryCreateOptions? _defaultOptions; /// Creates an instance for a method, specified via a delegate. /// The method to be represented via the created . /// The created for invoking . - [RequiresUnreferencedCode(UsesReflectionJsonSerializerMessage)] - [RequiresDynamicCode(UsesReflectionJsonSerializerMessage)] public static AIFunction Create(Delegate method) => Create(method, _defaultOptions ??= new()); /// Creates an instance for a method, specified via a delegate. @@ -52,8 +47,6 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions /// The name to use for the . /// The description to use for the . /// The created for invoking . - [RequiresUnreferencedCode("Reflection is used to access types from the supplied Delegate.")] - [RequiresDynamicCode("Reflection is used to access types from the supplied Delegate.")] public static AIFunction Create(Delegate method, string? name, string? description = null) => Create(method, (_defaultOptions ??= new()).SerializerOptions, name, description); @@ -80,8 +73,6 @@ public static AIFunction Create(Delegate method, JsonSerializerOptions options, /// This should be if and only if is a static method. /// /// The created for invoking . - [RequiresUnreferencedCode("Reflection is used to access types from the supplied MethodInfo.")] - [RequiresDynamicCode("Reflection is used to access types from the supplied MethodInfo.")] public static AIFunction Create(MethodInfo method, object? target = null) => Create(method, target, _defaultOptions ??= new()); @@ -107,8 +98,8 @@ private sealed class ReflectionAIFunction : AIFunction { private readonly MethodInfo _method; private readonly object? _target; - private readonly Func, AIFunctionContext?, object?>[] _parameterMarshalers; - private readonly Func> _returnMarshaler; + private readonly Func, AIFunctionContext?, object?>[] _parameterMarshallers; + private readonly Func> _returnMarshaller; private readonly JsonTypeInfo? _returnTypeInfo; private readonly bool _needsAIFunctionContext; @@ -185,11 +176,11 @@ static bool IsAsyncMethod(MethodInfo method) // Get marshaling delegates for parameters and build up the parameter metadata. var parameters = method.GetParameters(); - _parameterMarshalers = new Func, AIFunctionContext?, object?>[parameters.Length]; + _parameterMarshallers = new Func, AIFunctionContext?, object?>[parameters.Length]; bool sawAIContextParameter = false; for (int i = 0; i < parameters.Length; i++) { - if (GetParameterMarshaler(options.SerializerOptions, parameters[i], ref sawAIContextParameter, out _parameterMarshalers[i]) is AIFunctionParameterMetadata parameterView) + if (GetParameterMarshaller(options.SerializerOptions, parameters[i], ref sawAIContextParameter, out _parameterMarshallers[i]) is AIFunctionParameterMetadata parameterView) { parameterMetadata?.Add(parameterView); } @@ -198,7 +189,7 @@ static bool IsAsyncMethod(MethodInfo method) _needsAIFunctionContext = sawAIContextParameter; // Get the return type and a marshaling func for the return value. - Type returnType = GetReturnMarshaler(method, out _returnMarshaler); + Type returnType = GetReturnMarshaller(method, out _returnMarshaller); _returnTypeInfo = returnType != typeof(void) ? options.SerializerOptions.GetTypeInfo(returnType) : null; Metadata = new AIFunctionMetadata(functionName) @@ -224,8 +215,8 @@ static bool IsAsyncMethod(MethodInfo method) IEnumerable>? arguments, CancellationToken cancellationToken) { - var paramMarshalers = _parameterMarshalers; - object?[] args = paramMarshalers.Length != 0 ? new object?[paramMarshalers.Length] : []; + var paramMarshallers = _parameterMarshallers; + object?[] args = paramMarshallers.Length != 0 ? new object?[paramMarshallers.Length] : []; IReadOnlyDictionary argDict = arguments is null || args.Length == 0 ? EmptyReadOnlyDictionary.Instance : @@ -242,10 +233,10 @@ static bool IsAsyncMethod(MethodInfo method) for (int i = 0; i < args.Length; i++) { - args[i] = paramMarshalers[i](argDict, context); + args[i] = paramMarshallers[i](argDict, context); } - object? result = await _returnMarshaler(ReflectionInvoke(_method, _target, args)).ConfigureAwait(false); + object? result = await _returnMarshaller(ReflectionInvoke(_method, _target, args)).ConfigureAwait(false); switch (_returnTypeInfo) { @@ -271,11 +262,11 @@ static bool IsAsyncMethod(MethodInfo method) /// /// Gets a delegate for handling the marshaling of a parameter. /// - private static AIFunctionParameterMetadata? GetParameterMarshaler( + private static AIFunctionParameterMetadata? GetParameterMarshaller( JsonSerializerOptions options, ParameterInfo parameter, ref bool sawAIFunctionContext, - out Func, AIFunctionContext?, object?> marshaler) + out Func, AIFunctionContext?, object?> marshaller) { if (string.IsNullOrWhiteSpace(parameter.Name)) { @@ -292,7 +283,7 @@ static bool IsAsyncMethod(MethodInfo method) sawAIFunctionContext = true; - marshaler = static (_, ctx) => + marshaller = static (_, ctx) => { Debug.Assert(ctx is not null, "Expected a non-null context object."); return ctx; @@ -300,12 +291,12 @@ static bool IsAsyncMethod(MethodInfo method) return null; } - // Resolve the contract used to marshall the value from JSON -- can throw if not supported or not found. + // Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found. Type parameterType = parameter.ParameterType; JsonTypeInfo typeInfo = options.GetTypeInfo(parameterType); - // Create a marshaler that simply looks up the parameter by name in the arguments dictionary. - marshaler = (IReadOnlyDictionary arguments, AIFunctionContext? _) => + // Create a marshaller that simply looks up the parameter by name in the arguments dictionary. + marshaller = (IReadOnlyDictionary arguments, AIFunctionContext? _) => { // If the parameter has an argument specified in the dictionary, return that argument. if (arguments.TryGetValue(parameter.Name, out object? value)) @@ -368,7 +359,7 @@ static bool IsAsyncMethod(MethodInfo method) /// /// Gets a delegate for handling the result value of a method, converting it into the to return from the invocation. /// - private static Type GetReturnMarshaler(MethodInfo method, out Func> marshaler) + private static Type GetReturnMarshaller(MethodInfo method, out Func> marshaller) { // Handle each known return type for the method Type returnType = method.ReturnType; @@ -376,7 +367,7 @@ private static Type GetReturnMarshaler(MethodInfo method, out Func + marshaller = async static result => { await ((Task)ThrowIfNullResult(result)).ConfigureAwait(false); return null; @@ -387,7 +378,7 @@ private static Type GetReturnMarshaler(MethodInfo method, out Func + marshaller = async static result => { await ((ValueTask)ThrowIfNullResult(result)).ConfigureAwait(false); return null; @@ -401,7 +392,7 @@ private static Type GetReturnMarshaler(MethodInfo method, out Func)) { MethodInfo taskResultGetter = GetMethodFromGenericMethodDefinition(returnType, _taskGetResult); - marshaler = async result => + marshaller = async result => { await ((Task)ThrowIfNullResult(result)).ConfigureAwait(false); return ReflectionInvoke(taskResultGetter, result, null); @@ -414,7 +405,7 @@ private static Type GetReturnMarshaler(MethodInfo method, out Func + marshaller = async result => { var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(result), null)!; await task.ConfigureAwait(false); @@ -425,7 +416,7 @@ private static Type GetReturnMarshaler(MethodInfo method, out Func new ValueTask(result); + marshaller = result => new ValueTask(result); return returnType; // Throws an exception if a result is found to be null unexpectedly diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs index 8e0db9b4813..4a843d2bbde 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; using Microsoft.Shared.Diagnostics; @@ -19,10 +18,8 @@ public sealed class AIFunctionFactoryCreateOptions /// /// Initializes a new instance of the class with default serializer options. /// - [RequiresUnreferencedCode(AIFunctionFactory.UsesReflectionJsonSerializerMessage)] - [RequiresDynamicCode(AIFunctionFactory.UsesReflectionJsonSerializerMessage)] public AIFunctionFactoryCreateOptions() - : this(JsonSerializerOptions.Default) + : this(JsonDefaults.Options) { } diff --git a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs b/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs index f7aabcff6fd..7da71aa7fa0 100644 --- a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -30,9 +31,10 @@ private static JsonSerializerOptions CreateDefaultOptions() // Keep in sync with the JsonSourceGenerationOptions on JsonContext below. var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, }; options.MakeReadOnly(); @@ -45,7 +47,10 @@ private static JsonSerializerOptions CreateDefaultOptions() } // Keep in sync with CreateDefaultOptions above. - [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] [JsonSerializable(typeof(IList))] [JsonSerializable(typeof(ChatOptions))] [JsonSerializable(typeof(EmbeddingGenerationOptions))] @@ -57,7 +62,9 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(JsonDocument))] [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(int))] From 85e70b03ec9fd95ffb509e1c9c2ddd0453bc8d1b Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 11 Oct 2024 08:56:40 -0400 Subject: [PATCH 026/190] Use the logging generator in LoggingChatClient / LoggingEmbeddingGenerator (#5508) --- .../ChatCompletion/LoggingChatClient.cs | 112 ++++++++++++------ .../Embeddings/LoggingEmbeddingGenerator.cs | 43 ++++--- .../Microsoft.Extensions.AI.csproj | 1 + .../ChatClientIntegrationTests.cs | 8 +- 4 files changed, 108 insertions(+), 56 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs index f0a9e8a0d75..25a936eb646 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs @@ -10,13 +10,10 @@ using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; -#pragma warning disable EA0000 // Use source generated logging methods for improved performance -#pragma warning disable CA2254 // Template should be a static expression - namespace Microsoft.Extensions.AI; /// A delegating chat client that logs chat operations to an . -public class LoggingChatClient : DelegatingChatClient +public partial class LoggingChatClient : DelegatingChatClient { /// An instance used for all logging. private readonly ILogger _logger; @@ -45,7 +42,18 @@ public JsonSerializerOptions JsonSerializerOptions public override async Task CompleteAsync( IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) { - LogStart(chatMessages, options); + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokedSensitive(nameof(CompleteAsync), AsJson(chatMessages), AsJson(options), AsJson(Metadata)); + } + else + { + LogInvoked(nameof(CompleteAsync)); + } + } + try { var completion = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); @@ -54,20 +62,24 @@ public override async Task CompleteAsync( { if (_logger.IsEnabled(LogLevel.Trace)) { - _logger.Log(LogLevel.Trace, 0, (completion, _jsonSerializerOptions), null, static (state, _) => - $"CompleteAsync completed: {JsonSerializer.Serialize(state.completion, state._jsonSerializerOptions.GetTypeInfo(typeof(ChatCompletion)))}"); + LogCompletedSensitive(nameof(CompleteAsync), AsJson(completion)); } else { - _logger.LogDebug("CompleteAsync completed."); + LogCompleted(nameof(CompleteAsync)); } } return completion; } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(CompleteAsync)); + throw; + } + catch (Exception ex) { - _logger.LogError(ex, "CompleteAsync failed."); + LogInvocationFailed(nameof(CompleteAsync), ex); throw; } } @@ -76,16 +88,31 @@ public override async Task CompleteAsync( public override async IAsyncEnumerable CompleteStreamingAsync( IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - LogStart(chatMessages, options); + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokedSensitive(nameof(CompleteStreamingAsync), AsJson(chatMessages), AsJson(options), AsJson(Metadata)); + } + else + { + LogInvoked(nameof(CompleteStreamingAsync)); + } + } IAsyncEnumerator e; try { e = base.CompleteStreamingAsync(chatMessages, options, cancellationToken).GetAsyncEnumerator(cancellationToken); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(CompleteStreamingAsync)); + throw; + } + catch (Exception ex) { - _logger.LogError(ex, "CompleteStreamingAsync failed."); + LogInvocationFailed(nameof(CompleteStreamingAsync), ex); throw; } @@ -103,9 +130,14 @@ public override async IAsyncEnumerable CompleteSt update = e.Current; } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(CompleteStreamingAsync)); + throw; + } + catch (Exception ex) { - _logger.LogError(ex, "CompleteStreamingAsync failed."); + LogInvocationFailed(nameof(CompleteStreamingAsync), ex); throw; } @@ -113,19 +145,18 @@ public override async IAsyncEnumerable CompleteSt { if (_logger.IsEnabled(LogLevel.Trace)) { - _logger.Log(LogLevel.Trace, 0, (update, _jsonSerializerOptions), null, static (state, _) => - $"CompleteStreamingAsync received update: {JsonSerializer.Serialize(state.update, state._jsonSerializerOptions.GetTypeInfo(typeof(StreamingChatCompletionUpdate)))}"); + LogStreamingUpdateSensitive(AsJson(update)); } else { - _logger.LogDebug("CompleteStreamingAsync received update."); + LogStreamingUpdate(); } } yield return update; } - _logger.LogDebug("CompleteStreamingAsync completed."); + LogCompleted(nameof(CompleteStreamingAsync)); } finally { @@ -133,22 +164,29 @@ public override async IAsyncEnumerable CompleteSt } } - private void LogStart(IList chatMessages, ChatOptions? options, [CallerMemberName] string? methodName = null) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - if (_logger.IsEnabled(LogLevel.Trace)) - { - _logger.Log(LogLevel.Trace, 0, (methodName, chatMessages, options, this), null, static (state, _) => - $"{state.methodName} invoked: " + - $"Messages: {JsonSerializer.Serialize(state.chatMessages, state.Item4._jsonSerializerOptions.GetTypeInfo(typeof(IList)))}. " + - $"Options: {JsonSerializer.Serialize(state.options, state.Item4._jsonSerializerOptions.GetTypeInfo(typeof(ChatOptions)))}. " + - $"Metadata: {JsonSerializer.Serialize(state.Item4.Metadata, state.Item4._jsonSerializerOptions.GetTypeInfo(typeof(ChatClientMetadata)))}."); - } - else - { - _logger.LogDebug($"{methodName} invoked."); - } - } - } + private string AsJson(T value) => JsonSerializer.Serialize(value, _jsonSerializerOptions.GetTypeInfo(typeof(T))); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] + private partial void LogInvoked(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: {ChatMessages}. Options: {ChatOptions}. Metadata: {ChatClientMetadata}.")] + private partial void LogInvokedSensitive(string methodName, string chatMessages, string chatOptions, string chatClientMetadata); + + [LoggerMessage(LogLevel.Debug, "{MethodName} completed.")] + private partial void LogCompleted(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} completed: {ChatCompletion}.")] + private partial void LogCompletedSensitive(string methodName, string chatCompletion); + + [LoggerMessage(LogLevel.Debug, "CompleteStreamingAsync received update.")] + private partial void LogStreamingUpdate(); + + [LoggerMessage(LogLevel.Trace, "CompleteStreamingAsync received update: {StreamingChatCompletionUpdate}")] + private partial void LogStreamingUpdateSensitive(string streamingChatCompletionUpdate); + + [LoggerMessage(LogLevel.Debug, "{MethodName} canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); } diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs index b7981de8129..e42f51f602f 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs @@ -10,14 +10,12 @@ using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; -#pragma warning disable EA0000 // Use source generated logging methods for improved performance - namespace Microsoft.Extensions.AI; /// A delegating embedding generator that logs embedding generation operations to an . /// Specifies the type of the input passed to the generator. /// Specifies the type of the embedding instance produced by the generator. -public class LoggingEmbeddingGenerator : DelegatingEmbeddingGenerator +public partial class LoggingEmbeddingGenerator : DelegatingEmbeddingGenerator where TEmbedding : Embedding { /// An instance used for all logging. @@ -50,15 +48,11 @@ public override async Task> GenerateAsync(IEnume { if (_logger.IsEnabled(LogLevel.Trace)) { - _logger.Log(LogLevel.Trace, 0, (values, options, this), null, static (state, _) => - "GenerateAsync invoked: " + - $"Values: {JsonSerializer.Serialize(state.values, state.Item3._jsonSerializerOptions.GetTypeInfo(typeof(IEnumerable)))}. " + - $"Options: {JsonSerializer.Serialize(state.options, state.Item3._jsonSerializerOptions.GetTypeInfo(typeof(EmbeddingGenerationOptions)))}. " + - $"Metadata: {JsonSerializer.Serialize(state.Item3.Metadata, state.Item3._jsonSerializerOptions.GetTypeInfo(typeof(EmbeddingGeneratorMetadata)))}."); + LogInvokedSensitive(AsJson(values), AsJson(options), AsJson(Metadata)); } else { - _logger.LogDebug("GenerateAsync invoked."); + LogInvoked(); } } @@ -66,17 +60,36 @@ public override async Task> GenerateAsync(IEnume { var embeddings = await base.GenerateAsync(values, options, cancellationToken).ConfigureAwait(false); - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("GenerateAsync generated {Count} embedding(s).", embeddings.Count); - } + LogCompleted(embeddings.Count); return embeddings; } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (OperationCanceledException) + { + LogInvocationCanceled(); + throw; + } + catch (Exception ex) { - _logger.LogError(ex, "GenerateAsync failed."); + LogInvocationFailed(ex); throw; } } + + private string AsJson(T value) => JsonSerializer.Serialize(value, _jsonSerializerOptions.GetTypeInfo(typeof(T))); + + [LoggerMessage(LogLevel.Debug, "GenerateAsync invoked.")] + private partial void LogInvoked(); + + [LoggerMessage(LogLevel.Trace, "GenerateAsync invoked: {Values}. Options: {EmbeddingGenerationOptions}. Metadata: {EmbeddingGeneratorMetadata}.")] + private partial void LogInvokedSensitive(string values, string embeddingGenerationOptions, string embeddingGeneratorMetadata); + + [LoggerMessage(LogLevel.Debug, "GenerateAsync generated {EmbeddingsCount} embedding(s).")] + private partial void LogCompleted(int embeddingsCount); + + [LoggerMessage(LogLevel.Debug, "GenerateAsync canceled.")] + private partial void LogInvocationCanceled(); + + [LoggerMessage(LogLevel.Error, "GenerateAsync failed.")] + private partial void LogInvocationFailed(Exception error); } diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 8e389b61652..31beec15fe6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -21,6 +21,7 @@ true true + false diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 50257544430..09784e86d16 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -503,8 +503,8 @@ await chatClient.CompleteAsync( Assert.Collection(logger.Entries, entry => Assert.Contains("What is the current secret number?", entry.Message), - entry => Assert.Contains("\"name\":\"GetSecretNumber\"", entry.Message), - entry => Assert.Contains($"\"result\":{secretNumber}", entry.Message), + entry => Assert.Contains("\"name\": \"GetSecretNumber\"", entry.Message), + entry => Assert.Contains($"\"result\": {secretNumber}", entry.Message), entry => Assert.Contains(secretNumber.ToString(), entry.Message)); } @@ -528,8 +528,8 @@ public virtual async Task Logging_LogsFunctionCalls_Streaming() } Assert.Contains(logger.Entries, e => e.Message.Contains("What is the current secret number?")); - Assert.Contains(logger.Entries, e => e.Message.Contains("\"name\":\"GetSecretNumber\"")); - Assert.Contains(logger.Entries, e => e.Message.Contains($"\"result\":{secretNumber}")); + Assert.Contains(logger.Entries, e => e.Message.Contains("\"name\": \"GetSecretNumber\"")); + Assert.Contains(logger.Entries, e => e.Message.Contains($"\"result\": {secretNumber}")); } [ConditionalFact] From dbab2572a5cee1dd5624fbd3197df65a5b14b2f8 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 11 Oct 2024 08:57:07 -0400 Subject: [PATCH 027/190] Avoid use of FormattableString when logging (#5503) --- .../Emission/Emitter.Method.cs | 4 ++++ .../Logging/Internal/Log.cs | 7 +++++++ .../Logging/LoggerMessageHelper.cs | 15 +++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs b/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs index a7fa6b02e59..8b0f4e98727 100644 --- a/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs +++ b/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs @@ -116,7 +116,11 @@ private void GenLogMethod(LoggingMethod lm) }); var s = EscapeMessageString(mapped!); + OutLn($@"#if NET"); + OutLn($@"return string.Create(global::System.Globalization.CultureInfo.InvariantCulture, ${s});"); + OutLn($@"#else"); OutLn($@"return global::System.FormattableString.Invariant(${s});"); + OutLn($@"#endif"); } else if (string.IsNullOrEmpty(lm.Message)) { 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 ea60895faf9..c156eb72419 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs @@ -3,6 +3,9 @@ using System; using System.Diagnostics.CodeAnalysis; +#if NET +using System.Globalization; +#endif using System.Net.Http; using Microsoft.Extensions.Logging; @@ -152,7 +155,11 @@ private static string OriginalFormatValueFmt(LoggerMessageState request, Excepti var httpMethod = request[startIndex].Value; var httpHost = request[startIndex + 1].Value; var httpPath = request[startIndex + 2].Value; +#if NET + return string.Create(CultureInfo.InvariantCulture, stackalloc char[256], $"{httpMethod} {httpHost}/{httpPath}"); +#else return FormattableString.Invariant($"{httpMethod} {httpHost}/{httpPath}"); +#endif } private static int FindStartIndex(LoggerMessageState request) diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs index f74b4dac85c..bb3631909dc 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs @@ -5,6 +5,9 @@ using System.Collections; using System.Collections.Generic; using System.ComponentModel; +#if NET +using System.Globalization; +#endif using Microsoft.Shared.Pools; namespace Microsoft.Extensions.Logging; @@ -67,7 +70,11 @@ public static string Stringify(IEnumerable? enumerable) } else { +#if NET + _ = sb.Append(CultureInfo.InvariantCulture, $"\"{e}\""); +#else _ = sb.Append(FormattableString.Invariant($"\"{e}\"")); +#endif } first = false; @@ -108,7 +115,11 @@ public static string Stringify(IEnumerable Date: Fri, 11 Oct 2024 14:10:25 +0100 Subject: [PATCH 028/190] Rework the AIFunctionFactory APIs and remove redundant overloads following removal of trimmer annotations. --- .../Functions/AIFunctionFactory.cs | 54 ++++++------------- .../AIFunctionFactoryCreateOptions.cs | 18 +++---- .../Contents/FunctionCallContentTests..cs | 12 ++--- 3 files changed, 30 insertions(+), 54 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 24f5a96d75d..7473927db84 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; @@ -23,13 +22,8 @@ namespace Microsoft.Extensions.AI; /// Provides factory methods for creating commonly-used implementations of . public static class AIFunctionFactory { - /// Lazily-initialized default options instance. - private static AIFunctionFactoryCreateOptions? _defaultOptions; - - /// Creates an instance for a method, specified via a delegate. - /// The method to be represented via the created . - /// The created for invoking . - public static AIFunction Create(Delegate method) => Create(method, _defaultOptions ??= new()); + /// Holds the default options instance used when creating function. + private static readonly AIFunctionFactoryCreateOptions _defaultOptions = new(); /// Creates an instance for a method, specified via a delegate. /// The method to be represented via the created . @@ -38,7 +32,6 @@ public static class AIFunctionFactory public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions options) { _ = Throw.IfNull(method); - _ = Throw.IfNull(options); return new ReflectionAIFunction(method.Method, method.Target, options); } @@ -46,35 +39,23 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions /// The method to be represented via the created . /// The name to use for the . /// The description to use for the . + /// The used to marshal function parameters. /// The created for invoking . - public static AIFunction Create(Delegate method, string? name, string? description = null) - => Create(method, (_defaultOptions ??= new()).SerializerOptions, name, description); - - /// Creates an instance for a method, specified via a delegate. - /// The method to be represented via the created . - /// The used to marshal function parameters. - /// The name to use for the . - /// The description to use for the . - /// The created for invoking . - public static AIFunction Create(Delegate method, JsonSerializerOptions options, string? name = null, string? description = null) + public static AIFunction Create(Delegate method, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null) { _ = Throw.IfNull(method); - _ = Throw.IfNull(options); - return new ReflectionAIFunction(method.Method, method.Target, new(options) { Name = name, Description = description }); - } - /// - /// Creates an instance for a method, specified via an instance - /// and an optional target object if the method is an instance method. - /// - /// The method to be represented via the created . - /// - /// The target object for the if it represents an instance method. - /// This should be if and only if is a static method. - /// - /// The created for invoking . - public static AIFunction Create(MethodInfo method, object? target = null) - => Create(method, target, _defaultOptions ??= new()); + AIFunctionFactoryCreateOptions createOptions = serializerOptions is null && name is null && description is null + ? _defaultOptions + : new() + { + SerializerOptions = serializerOptions ?? _defaultOptions.SerializerOptions, + Name = name, + Description = description + }; + + return new ReflectionAIFunction(method.Method, method.Target, createOptions); + } /// /// Creates an instance for a method, specified via an instance @@ -87,11 +68,10 @@ public static AIFunction Create(MethodInfo method, object? target = null) /// /// Metadata to use to override defaults inferred from . /// The created for invoking . - public static AIFunction Create(MethodInfo method, object? target, AIFunctionFactoryCreateOptions options) + public static AIFunction Create(MethodInfo method, object? target = null, AIFunctionFactoryCreateOptions? options = null) { _ = Throw.IfNull(method); - _ = Throw.IfNull(options); - return new ReflectionAIFunction(method, target, options); + return new ReflectionAIFunction(method, target, options ?? _defaultOptions); } private sealed class ReflectionAIFunction : AIFunction diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs index 4a843d2bbde..8b1ce34bc33 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs @@ -15,26 +15,22 @@ namespace Microsoft.Extensions.AI; /// public sealed class AIFunctionFactoryCreateOptions { + private JsonSerializerOptions _options = JsonDefaults.Options; + /// - /// Initializes a new instance of the class with default serializer options. + /// Initializes a new instance of the class. /// public AIFunctionFactoryCreateOptions() - : this(JsonDefaults.Options) { } - /// - /// Initializes a new instance of the class. - /// - /// The JSON serialization options used to marshal .NET types. - public AIFunctionFactoryCreateOptions(JsonSerializerOptions serializerOptions) + /// Gets or sets the used to marshal .NET values being passed to the underlying delegate. + public JsonSerializerOptions SerializerOptions { - SerializerOptions = Throw.IfNull(serializerOptions); + get => _options; + set => _options = Throw.IfNull(value); } - /// Gets the used to marshal .NET values being passed to the underlying delegate. - public JsonSerializerOptions SerializerOptions { get; } - /// Gets or sets the name to use for the function. /// /// If , it will default to one derived from the method represented by the passed or . diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs index 054b0eeefec..ad513574055 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs @@ -116,7 +116,7 @@ public async Task AIFunctionFactory_ObjectValues_Converted() })), }; - AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, TestJsonSerializerContext.Default.Options); + AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, serializerOptions: TestJsonSerializerContext.Default.Options); var result = await function.InvokeAsync(arguments); AssertExtensions.EqualFunctionCallResults(123.4, result); } @@ -138,7 +138,7 @@ public async Task AIFunctionFactory_JsonElementValues_ValuesDeserialized() """, TestJsonSerializerContext.Default.Options)!; Assert.All(arguments.Values, v => Assert.IsType(v)); - AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, TestJsonSerializerContext.Default.Options); + AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, serializerOptions: TestJsonSerializerContext.Default.Options); var result = await function.InvokeAsync(arguments); AssertExtensions.EqualFunctionCallResults(123.4, result); } @@ -146,11 +146,11 @@ public async Task AIFunctionFactory_JsonElementValues_ValuesDeserialized() [Fact] public void AIFunctionFactory_WhenTypesUnknownByContext_Throws() { - var ex = Assert.Throws(() => AIFunctionFactory.Create((CustomType arg) => { }, TestJsonSerializerContext.Default.Options)); + var ex = Assert.Throws(() => AIFunctionFactory.Create((CustomType arg) => { }, serializerOptions: TestJsonSerializerContext.Default.Options)); Assert.Contains("JsonTypeInfo metadata", ex.Message); Assert.Contains(nameof(CustomType), ex.Message); - ex = Assert.Throws(() => AIFunctionFactory.Create(() => new CustomType(), TestJsonSerializerContext.Default.Options)); + ex = Assert.Throws(() => AIFunctionFactory.Create(() => new CustomType(), serializerOptions: TestJsonSerializerContext.Default.Options)); Assert.Contains("JsonTypeInfo metadata", ex.Message); Assert.Contains(nameof(CustomType), ex.Message); } @@ -171,7 +171,7 @@ public async Task AIFunctionFactory_JsonDocumentValues_ValuesDeserialized() } """, TestJsonSerializerContext.Default.Options)!.ToDictionary(k => k.Key, k => (object?)k.Value); - AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, TestJsonSerializerContext.Default.Options); + AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, serializerOptions: TestJsonSerializerContext.Default.Options); var result = await function.InvokeAsync(arguments); AssertExtensions.EqualFunctionCallResults(123.4, result); } @@ -192,7 +192,7 @@ public async Task AIFunctionFactory_JsonNodeValues_ValuesDeserialized() } """, TestJsonSerializerContext.Default.Options)!.ToDictionary(k => k.Key, k => (object?)k.Value); - AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, TestJsonSerializerContext.Default.Options); + AIFunction function = AIFunctionFactory.Create((DayOfWeek[] a, double b, Guid c, Dictionary d) => b, serializerOptions: TestJsonSerializerContext.Default.Options); var result = await function.InvokeAsync(arguments); AssertExtensions.EqualFunctionCallResults(123.4, result); } From b708fa905cd8cf1646b287d9f2d9739fafd0fb96 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 11 Oct 2024 14:13:13 +0100 Subject: [PATCH 029/190] Reinstate null check. --- .../Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 7473927db84..35b79c795df 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -32,6 +32,7 @@ public static class AIFunctionFactory public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions options) { _ = Throw.IfNull(method); + _ = Throw.IfNull(options); return new ReflectionAIFunction(method.Method, method.Target, options); } From 65fb55f6f3613f2d132e31a3f514be95239509bd Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 11 Oct 2024 14:21:10 +0100 Subject: [PATCH 030/190] Make parameter nullable again. --- .../Functions/AIFunctionFactory.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 35b79c795df..8a2af4e5779 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -29,11 +29,11 @@ public static class AIFunctionFactory /// The method to be represented via the created . /// Metadata to use to override defaults inferred from . /// The created for invoking . - public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions options) + public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions? options) { _ = Throw.IfNull(method); - _ = Throw.IfNull(options); - return new ReflectionAIFunction(method.Method, method.Target, options); + + return new ReflectionAIFunction(method.Method, method.Target, options ?? _defaultOptions); } /// Creates an instance for a method, specified via a delegate. @@ -46,7 +46,7 @@ public static AIFunction Create(Delegate method, string? name = null, string? de { _ = Throw.IfNull(method); - AIFunctionFactoryCreateOptions createOptions = serializerOptions is null && name is null && description is null + AIFunctionFactoryCreateOptions? createOptions = serializerOptions is null && name is null && description is null ? _defaultOptions : new() { From 02eba55d3551dea3203a8baf277a85e095854e7b Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 11 Oct 2024 14:28:10 +0100 Subject: [PATCH 031/190] Rework the `MethodInfo` overload to match `Delegate` overload. --- .../Functions/AIFunctionFactory.cs | 33 +++++++++++++++++-- .../Functions/AIFunctionFactoryTest.cs | 7 ++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 8a2af4e5779..e48098b378f 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -46,7 +46,7 @@ public static AIFunction Create(Delegate method, string? name = null, string? de { _ = Throw.IfNull(method); - AIFunctionFactoryCreateOptions? createOptions = serializerOptions is null && name is null && description is null + AIFunctionFactoryCreateOptions createOptions = serializerOptions is null && name is null && description is null ? _defaultOptions : new() { @@ -69,12 +69,41 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// /// Metadata to use to override defaults inferred from . /// The created for invoking . - public static AIFunction Create(MethodInfo method, object? target = null, AIFunctionFactoryCreateOptions? options = null) + public static AIFunction Create(MethodInfo method, object? target, AIFunctionFactoryCreateOptions? options) { _ = Throw.IfNull(method); return new ReflectionAIFunction(method, target, options ?? _defaultOptions); } + /// + /// Creates an instance for a method, specified via an instance + /// and an optional target object if the method is an instance method. + /// + /// The method to be represented via the created . + /// + /// The target object for the if it represents an instance method. + /// This should be if and only if is a static method. + /// + /// The name to use for the . + /// The description to use for the . + /// The used to marshal function parameters. + /// The created for invoking . + public static AIFunction Create(MethodInfo method, object? target, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null) + { + _ = Throw.IfNull(method); + + AIFunctionFactoryCreateOptions? createOptions = serializerOptions is null && name is null && description is null + ? _defaultOptions + : new() + { + SerializerOptions = serializerOptions ?? _defaultOptions.SerializerOptions, + Name = name, + Description = description + }; + + return new ReflectionAIFunction(method, target, createOptions); + } + private sealed class ReflectionAIFunction : AIFunction { private readonly MethodInfo _method; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 41ed51cd2a2..7d8b10814d4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -16,9 +15,9 @@ public class AIFunctionFactoryTest [Fact] public void InvalidArguments_Throw() { - Delegate nullDelegate = null!; - Assert.Throws(() => AIFunctionFactory.Create(nullDelegate)); - Assert.Throws(() => AIFunctionFactory.Create((MethodInfo)null!)); + Assert.Throws(() => AIFunctionFactory.Create(method: null!)); + Assert.Throws(() => AIFunctionFactory.Create(method: null!, target: new object())); + Assert.Throws(() => AIFunctionFactory.Create(method: null!, target: new object(), name: "myAiFunk")); Assert.Throws(() => AIFunctionFactory.Create(typeof(AIFunctionFactoryTest).GetMethod(nameof(InvalidArguments_Throw))!, null)); Assert.Throws(() => AIFunctionFactory.Create(typeof(List<>).GetMethod("Add")!, new List())); } From 22d55bf0817302831f3c7a1bbbda68a23d247d6e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 11 Oct 2024 16:48:20 +0100 Subject: [PATCH 032/190] Build fix - remove unnecessary 'using' --- .../Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 24f5a96d75d..ad796e5ef5b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; From 99fdb98b30d4b7ca750406a72f6825f2b2acd0d5 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 11 Oct 2024 12:07:02 -0400 Subject: [PATCH 033/190] Add comment about use of hashing in CachingHelpers (#5509) --- src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs b/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs index 8128926f942..13637dc5226 100644 --- a/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs +++ b/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs @@ -44,7 +44,10 @@ public static string GetCacheKey(TValue value, bool flag, JsonSerializer } // The complete JSON representation is excessively long for a cache key, duplicating much of the content - // from the value. So we use a hash of it as the default key. + // from the value. So we use a hash of it as the default key, and we rely on collision resistance for security purposes. + // If a collision occurs, we'd serve the cached LLM response for a potentially unrelated prompt, leading to information + // disclosure. Use of SHA256 is an implementation detail and can be easily swapped in the future if needed, albeit + // invalidating any existing cache entries that may exist in whatever IDistributedCache was in use. #if NET8_0_OR_GREATER Span hashData = stackalloc byte[SHA256.HashSizeInBytes]; SHA256.HashData(jsonKeyBytes, hashData); From e0c9a82fed6abb3539b195e967014c0fa9699177 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 12 Oct 2024 07:27:25 -0400 Subject: [PATCH 034/190] Improve CachingChatClient's coalescing of streaming updates (#5514) * Improve CachingChatClient's coalescing of streaming updates - Avoid O(N^2) memory allocation in the length of the received text - Propagate additional metadata from coalesced nodes - Propagate metadata on the coalesced TextContent, like ModelId - Expose whether to coalesce as a setting on the client * Remove dictionary merging until we have evidence it's warranted --- .../AdditionalPropertiesDictionary.cs | 7 + .../ChatCompletion/ChatOptions.cs | 6 +- .../Embeddings/EmbeddingGenerationOptions.cs | 14 +- .../ChatCompletion/CachingChatClient.cs | 155 ++++++++++++++---- .../DistributedCachingChatClientTest.cs | 114 ++++++++++++- 5 files changed, 238 insertions(+), 58 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs index 5ffc76260d9..28b513cda4a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs @@ -40,6 +40,13 @@ public AdditionalPropertiesDictionary(IEnumerable> #endif } + /// Creates a shallow clone of the properties dictionary. + /// + /// A shallow clone of the properties dictionary. The instance will not be the same as the current instance, + /// but it will contain all of the same key-value pairs. + /// + public AdditionalPropertiesDictionary Clone() => new AdditionalPropertiesDictionary(_dictionary); + /// public object? this[string key] { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index 21224454000..4f02815580e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -73,6 +73,7 @@ public virtual ChatOptions Clone() ResponseFormat = ResponseFormat, ModelId = ModelId, ToolMode = ToolMode, + AdditionalProperties = AdditionalProperties?.Clone(), }; if (StopSequences is not null) @@ -85,11 +86,6 @@ public virtual ChatOptions Clone() options.Tools = new List(Tools); } - if (AdditionalProperties is not null) - { - options.AdditionalProperties = new(AdditionalProperties); - } - return options; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs index bd010d5f447..02875e9de98 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs @@ -18,18 +18,10 @@ public class EmbeddingGenerationOptions /// The clone will have the same values for all properties as the original instance. Any collections, like /// are shallow-cloned, meaning a new collection instance is created, but any references contained by the collections are shared with the original. /// - public virtual EmbeddingGenerationOptions Clone() - { - EmbeddingGenerationOptions options = new() + public virtual EmbeddingGenerationOptions Clone() => + new() { ModelId = ModelId, + AdditionalProperties = AdditionalProperties?.Clone(), }; - - if (AdditionalProperties is not null) - { - options.AdditionalProperties = new(AdditionalProperties); - } - - return options; - } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs index 89a778cdd1b..a12061a1028 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs @@ -3,10 +3,13 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; +#pragma warning disable S127 // "for" loop stop conditions should be invariant + namespace Microsoft.Extensions.AI; /// @@ -21,6 +24,20 @@ protected CachingChatClient(IChatClient innerClient) { } + /// Gets or sets a value indicating whether to coalesce streaming updates. + /// + /// + /// When , the client will attempt to coalesce contiguous streaming updates + /// into a single update, in order to reduce the number of individual items that are yielded on + /// subsequent enumerations of the cached data. When , the updates are + /// kept unaltered. + /// + /// + /// The default is . + /// + /// + public bool CoalesceStreamingUpdates { get; set; } = true; + /// public override async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) { @@ -50,6 +67,7 @@ public override async IAsyncEnumerable CompleteSt var cacheKey = GetCacheKey(true, chatMessages, options); if (await ReadCacheStreamingAsync(cacheKey, cancellationToken).ConfigureAwait(false) is { } existingChunks) { + // Yield all of the cached items. foreach (var chunk in existingChunks) { yield return chunk; @@ -57,51 +75,116 @@ public override async IAsyncEnumerable CompleteSt } else { - var capturedItems = new List(); - StreamingChatCompletionUpdate? previousCoalescedCopy = null; - await foreach (var item in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) + // Yield and store all of the items. + List capturedItems = []; + await foreach (var chunk in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) { - yield return item; - - // If this item is compatible with the previous one, we will coalesce them in the cache - var previous = capturedItems.Count > 0 ? capturedItems[capturedItems.Count - 1] : null; - if (item.ChoiceIndex == 0 - && item.Contents.Count == 1 - && item.Contents[0] is TextContent currentTextContent - && previous is { ChoiceIndex: 0 } - && previous.Role == item.Role - && previous.Contents is { Count: 1 } - && previous.Contents[0] is TextContent previousTextContent) + capturedItems.Add(chunk); + yield return chunk; + } + + // If the caching client is configured to coalesce streaming updates, do so now within the capturedItems list. + if (CoalesceStreamingUpdates) + { + StringBuilder coalescedText = new(); + + // Iterate through all of the items in the list looking for contiguous items that can be coalesced. + for (int startInclusive = 0; startInclusive < capturedItems.Count; startInclusive++) { - if (!ReferenceEquals(previous, previousCoalescedCopy)) + // If an item isn't generally coalescable, skip it. + StreamingChatCompletionUpdate update = capturedItems[startInclusive]; + if (update.ChoiceIndex != 0 || + update.Contents.Count != 1 || + update.Contents[0] is not TextContent textContent) { - // We don't want to mutate any object that we also yield, since the recipient might - // not expect that. Instead make a copy we can safely mutate. - previousCoalescedCopy = new() + continue; + } + + // We found a coalescable item. Look for more contiguous items that are also coalescable with it. + int endExclusive = startInclusive + 1; + for (; endExclusive < capturedItems.Count; endExclusive++) + { + StreamingChatCompletionUpdate next = capturedItems[endExclusive]; + if (next.ChoiceIndex != 0 || + next.Contents.Count != 1 || + next.Contents[0] is not TextContent || + + // changing role or author would be really strange, but check anyway + (update.Role is not null && next.Role is not null && update.Role != next.Role) || + (update.AuthorName is not null && next.AuthorName is not null && update.AuthorName != next.AuthorName)) { - Role = previous.Role, - AuthorName = previous.AuthorName, - AdditionalProperties = previous.AdditionalProperties, - ChoiceIndex = previous.ChoiceIndex, - RawRepresentation = previous.RawRepresentation, - Contents = [new TextContent(previousTextContent.Text)] - }; - - // The last item we captured was before we knew it could be coalesced - // with this one, so replace it with the coalesced copy - capturedItems[capturedItems.Count - 1] = previousCoalescedCopy; + break; + } } -#pragma warning disable S1643 // Strings should not be concatenated using '+' in a loop - ((TextContent)previousCoalescedCopy.Contents[0]).Text += currentTextContent.Text; -#pragma warning restore S1643 - } - else - { - capturedItems.Add(item); + // If we couldn't find anything to coalesce, there's nothing to do. + if (endExclusive - startInclusive <= 1) + { + continue; + } + + // We found a coalescable run of items. Create a new node to represent the run. We create a new one + // rather than reappropriating one of the existing ones so as not to mutate an item already yielded. + _ = coalescedText.Clear().Append(capturedItems[startInclusive].Text); + + TextContent coalescedContent = new(null) // will patch the text after examining all items in the run + { + AdditionalProperties = textContent.AdditionalProperties?.Clone(), + ModelId = textContent.ModelId, + }; + + StreamingChatCompletionUpdate coalesced = new() + { + AdditionalProperties = update.AdditionalProperties?.Clone(), + AuthorName = update.AuthorName, + CompletionId = update.CompletionId, + Contents = [coalescedContent], + CreatedAt = update.CreatedAt, + FinishReason = update.FinishReason, + Role = update.Role, + + // Explicitly don't include RawRepresentation. It's not applicable if one update ends up being used + // to represent multiple, and it won't be serialized anyway. + }; + + // Replace the starting node with the coalesced node. + capturedItems[startInclusive] = coalesced; + + // Now iterate through all the rest of the updates in the run, updating the coalesced node with relevant properties, + // and nulling out the nodes along the way. We do this rather than removing the entry in order to avoid an O(N^2) operation. + // We'll remove all the null entries at the end of the loop, using RemoveAll to do so, which can remove all of + // the nulls in a single O(N) pass. + for (int i = startInclusive + 1; i < endExclusive; i++) + { + // Grab the next item. + StreamingChatCompletionUpdate next = capturedItems[i]; + capturedItems[i] = null!; + + TextContent nextContent = (TextContent)next.Contents[0]; + _ = coalescedText.Append(nextContent.Text); + + coalesced.AuthorName ??= next.AuthorName; + coalesced.CompletionId ??= next.CompletionId; + coalesced.CreatedAt ??= next.CreatedAt; + coalesced.FinishReason ??= next.FinishReason; + coalesced.Role ??= next.Role; + + coalescedContent.ModelId ??= nextContent.ModelId; + } + + // Complete the coalescing by patching the text of the coalesced node. + coalesced.Text = coalescedText.ToString(); + + // Jump to the last update in the run, so that when we loop around and bump ahead, + // we're at the next update just after the run. + startInclusive = endExclusive - 1; } + + // Remove all of the null slots left over from the coalescing process. + _ = capturedItems.RemoveAll(u => u is null); } + // Write the captured items to the cache. await WriteCacheStreamingAsync(cacheKey, capturedItems, cancellationToken).ConfigureAwait(false); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs index 35ced372eb2..158c55aee7a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs @@ -17,6 +17,21 @@ public class DistributedCachingChatClientTest { private readonly TestInMemoryCacheStorage _storage = new(); + [Fact] + public void Ctor_ExpectedDefaults() + { + using var innerClient = new TestChatClient(); + using var cachingClient = new DistributedCachingChatClient(innerClient, _storage); + + Assert.True(cachingClient.CoalesceStreamingUpdates); + + cachingClient.CoalesceStreamingUpdates = false; + Assert.False(cachingClient.CoalesceStreamingUpdates); + + cachingClient.CoalesceStreamingUpdates = true; + Assert.True(cachingClient.CoalesceStreamingUpdates); + } + [Fact] public async Task CachesSuccessResultsAsync() { @@ -251,8 +266,11 @@ public async Task StreamingCachesSuccessResultsAsync() Assert.Equal(2, innerCallCount); } - [Fact] - public async Task StreamingCoalescesConsecutiveTextChunksAsync() + [Theory] + [InlineData(false)] + [InlineData(true)] + [InlineData(null)] + public async Task StreamingCoalescesConsecutiveTextChunksAsync(bool? coalesce) { // Arrange List expectedCompletion = @@ -274,6 +292,83 @@ public async Task StreamingCoalescesConsecutiveTextChunksAsync() JsonSerializerOptions = TestJsonSerializerContext.Default.Options }; + if (coalesce is not null) + { + outer.CoalesceStreamingUpdates = coalesce.Value; + } + + var result1 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); + await ToListAsync(result1); + + // Act + var result2 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); + + // Assert + if (coalesce is null or true) + { + Assert.Collection(await ToListAsync(result2), + c => Assert.Equal("This becomes one chunk", c.Text), + c => Assert.IsType(Assert.Single(c.Contents)), + c => Assert.Equal("... and this becomes another one.", c.Text)); + } + else + { + Assert.Collection(await ToListAsync(result2), + c => Assert.Equal("This", c.Text), + c => Assert.Equal(" becomes one chunk", c.Text), + c => Assert.IsType(Assert.Single(c.Contents)), + c => Assert.Equal("... and this", c.Text), + c => Assert.Equal(" becomes another", c.Text), + c => Assert.Equal(" one.", c.Text)); + } + } + + [Fact] + public async Task StreamingCoalescingPropagatesMetadataAsync() + { + // Arrange + List expectedCompletion = + [ + new() { Role = ChatRole.Assistant, Contents = [new TextContent("Hello")] }, + new() { Role = ChatRole.Assistant, Contents = [new TextContent(" world, ") { ModelId = "some model" }] }, + new() + { + Role = ChatRole.Assistant, + Contents = + [ + new TextContent("how ") + { + ModelId = "some other model", + AdditionalProperties = new() { ["a"] = "b", ["c"] = "d" }, + } + ] + }, + new() + { + Role = ChatRole.Assistant, + Contents = + [ + new TextContent("are you?") + { + AdditionalProperties = new() { ["e"] = "f", ["g"] = "h" }, + } + ], + CreatedAt = DateTime.Parse("2024-10-11T19:23:36.0152137Z"), + CompletionId = "12345", + AuthorName = "Someone", + FinishReason = ChatFinishReason.Length, + }, + ]; + + using var testClient = new TestChatClient + { + CompleteStreamingAsyncCallback = delegate { return ToAsyncEnumerableAsync(expectedCompletion); } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + var result1 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); await ToListAsync(result1); @@ -281,10 +376,17 @@ public async Task StreamingCoalescesConsecutiveTextChunksAsync() var result2 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); // Assert - Assert.Collection(await ToListAsync(result2), - c => Assert.Equal("This becomes one chunk", c.Text), - c => Assert.IsType(Assert.Single(c.Contents)), - c => Assert.Equal("... and this becomes another one.", c.Text)); + var items = await ToListAsync(result2); + var item = Assert.Single(items); + Assert.Equal("Hello world, how are you?", item.Text); + Assert.Equal("12345", item.CompletionId); + Assert.Equal("Someone", item.AuthorName); + Assert.Equal(ChatFinishReason.Length, item.FinishReason); + Assert.Equal(DateTime.Parse("2024-10-11T19:23:36.0152137Z"), item.CreatedAt); + + var content = Assert.IsType(Assert.Single(item.Contents)); + Assert.Equal("Hello world, how are you?", content.Text); + Assert.Equal("some model", content.ModelId); } [Fact] From 412305bd1a89aea8925b8f30d4e46d7b1e7e6575 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 12 Oct 2024 08:12:56 -0400 Subject: [PATCH 035/190] Add thread-safety comments about M.E.AI middleware components (#5515) --- .../ChatCompletion/IChatClient.cs | 14 +++++++++ .../Embeddings/IEmbeddingGenerator.cs | 13 ++++++++ .../ConfigureOptionsChatClient.cs | 7 +++++ .../DistributedCachingChatClient.cs | 4 +++ .../FunctionInvokingChatClient.cs | 31 +++++++++++++++++++ .../ChatCompletion/LoggingChatClient.cs | 4 +++ .../DistributedCachingEmbeddingGenerator.cs | 4 +++ .../Embeddings/LoggingEmbeddingGenerator.cs | 4 +++ 8 files changed, 81 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs index e9839cab2ae..8cbfa1314f4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs @@ -9,6 +9,20 @@ namespace Microsoft.Extensions.AI; /// Represents a chat completion client. +/// +/// +/// Unless otherwise specified, all members of are thread-safe for concurrent use. +/// It is expected that all implementations of support being used by multiple requests concurrently. +/// +/// +/// However, implementations of may mutate the arguments supplied to and +/// , such as by adding additional messages to the messages list or configuring the options +/// instance. Thus, consumers of the interface either should avoid using shared instances of these arguments for concurrent +/// invocations or should otherwise ensure by construction that no instances are used which might employ +/// such mutation. For example, the WithChatOptions method be provided with a callback that could mutate the supplied options +/// argument, and that should be avoided if using a singleton options instance. +/// +/// public interface IChatClient : IDisposable { /// Sends chat messages to the model and returns the response messages. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs index 6c791ee2bf4..5cc289fbb5e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs @@ -11,6 +11,19 @@ namespace Microsoft.Extensions.AI; /// Represents a generator of embeddings. /// The type from which embeddings will be generated. /// The type of embeddings to generate. +/// +/// +/// Unless otherwise specified, all members of are thread-safe for concurrent use. +/// It is expected that all implementations of support being used by multiple requests concurrently. +/// +/// +/// However, implementations of may mutate the arguments supplied to +/// , such as by adding additional values to the values list or configuring the options +/// instance. Thus, consumers of the interface either should avoid using shared instances of these arguments for concurrent +/// invocations or should otherwise ensure by construction that no instances +/// are used which might employ such mutation. +/// +/// public interface IEmbeddingGenerator : IDisposable where TEmbedding : Embedding { diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs index a8a4b9269e2..895bf8873df 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs @@ -14,6 +14,7 @@ namespace Microsoft.Extensions.AI; /// A delegating chat client that updates or replaces the used by the remainder of the pipeline. /// +/// /// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options /// with a new instance, the callback may simply return that new instance, for example _ => new ChatOptions() { MaxTokens = 1000 }. To provide /// a new instance only if the caller-supplied instance is `null`, the callback may conditionally return a new instance, for example @@ -28,6 +29,12 @@ namespace Microsoft.Extensions.AI; /// return newOptions; /// } /// +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the employed configuration +/// callback is also thread-safe for concurrent requests. If callers employ a shared options instance, care should be taken in the +/// configuration callback, as multiple calls to it may end up running in parallel with the same options instance. +/// /// public sealed class ConfigureOptionsChatClient : DelegatingChatClient { diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs index 65c50c090bd..8c247d73fb3 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs @@ -13,6 +13,10 @@ namespace Microsoft.Extensions.AI; /// /// A delegating chat client that caches the results of completion calls, storing them as JSON in an . /// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the employed +/// is similarly thread-safe for concurrent use. +/// public class DistributedCachingChatClient : CachingChatClient { private readonly IDistributedCache _storage; diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 16e9d62f25b..94b87c9a7b1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -17,9 +17,22 @@ namespace Microsoft.Extensions.AI; /// Include this in a chat pipeline to resolve function calls automatically. /// /// +/// /// When this client receives a in a chat completion, it responds /// by calling the corresponding defined in , /// producing a . +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// instances employed as part of the supplied are also safe. +/// The property may be used to control whether multiple function invocation +/// requests as part of the same request are invocable concurrently, but even with that set to +/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those +/// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific +/// ASP.NET web request should only be used as part of a single at a time, and only with +/// set to , in case the inner client decided to issue multiple +/// invocation requests to that same function. +/// /// public class FunctionInvokingChatClient : DelegatingChatClient { @@ -49,6 +62,10 @@ public FunctionInvokingChatClient(IChatClient innerClient) /// to continue attempting function calls until is reached. /// /// + /// Changing the value of this property while the client is in use may result in inconsistencies + /// as to whether errors are retried during an in-flight request. + /// + /// /// The default value is . /// /// @@ -73,6 +90,10 @@ public FunctionInvokingChatClient(IChatClient innerClient) /// result in disclosing the raw exception information to external users, which may be a security /// concern depending on the application scenario. /// + /// + /// Changing the value of this property while the client is in use may result in inconsistencies + /// as to whether detailed errors are provided during an in-flight request. + /// /// public bool DetailedErrors { get; set; } @@ -95,6 +116,7 @@ public FunctionInvokingChatClient(IChatClient innerClient) /// Gets or sets a value indicating whether to keep intermediate messages in the chat history. /// /// + /// /// When the inner returns to the /// , the adds /// those messages to the list of messages, along with instances @@ -104,6 +126,11 @@ public FunctionInvokingChatClient(IChatClient innerClient) /// messages will persist in the list provided to /// and by the caller. Set /// to to remove those messages prior to completing the operation. + /// + /// + /// Changing the value of this property while the client is in use may result in inconsistencies + /// as to whether function calling messages are kept during an in-flight request. + /// /// public bool KeepFunctionCallingMessages { get; set; } = true; @@ -120,6 +147,10 @@ public FunctionInvokingChatClient(IChatClient innerClient) /// must be at least one, as it includes the initial request. /// /// + /// Changing the value of this property while the client is in use may result in inconsistencies + /// as to how many iterations are allowed for an in-flight request. + /// + /// /// The default value is . /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs index 25a936eb646..1c268aa08a9 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs @@ -13,6 +13,10 @@ namespace Microsoft.Extensions.AI; /// A delegating chat client that logs chat operations to an . +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// employed is also thread-safe for concurrent use. +/// public partial class LoggingChatClient : DelegatingChatClient { /// An instance used for all logging. diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs index 932bb2f91b8..eda857462a2 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs @@ -16,6 +16,10 @@ namespace Microsoft.Extensions.AI; /// /// The type from which embeddings will be generated. /// The type of embeddings to generate. +/// +/// The provided implementation of is thread-safe for concurrent +/// use so long as the employed is similarly thread-safe for concurrent use. +/// public class DistributedCachingEmbeddingGenerator : CachingEmbeddingGenerator where TEmbedding : Embedding { diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs index e42f51f602f..cef4c203020 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs @@ -15,6 +15,10 @@ namespace Microsoft.Extensions.AI; /// A delegating embedding generator that logs embedding generation operations to an . /// Specifies the type of the input passed to the generator. /// Specifies the type of the embedding instance produced by the generator. +/// +/// The provided implementation of is thread-safe for concurrent use +/// so long as the employed is also thread-safe for concurrent use. +/// public partial class LoggingEmbeddingGenerator : DelegatingEmbeddingGenerator where TEmbedding : Embedding { From 87308c7919d1e572b96fbbfd7ff637688a0efb84 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 14 Oct 2024 12:59:49 -0400 Subject: [PATCH 036/190] Update README with rate limiting example (#5519) Replace the logging examples as there's already a built-in logging implementation. --- .../README.md | 87 +++++++++++-------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md index 4cacbda0a4f..9cbe166233a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md @@ -262,38 +262,40 @@ for (int i = 0; i < 3; i++) Anyone can layer in such additional functionality. While it's possible to implement `IChatClient` directly, the `DelegatingChatClient` class is an implementation of the `IChatClient` interface that serves as a base class for creating chat clients that delegate their operations to another `IChatClient` instance. It is designed to facilitate the chaining of multiple clients, allowing calls to be passed through to an underlying client. The class provides default implementations for methods such as `CompleteAsync`, `CompleteStreamingAsync`, and `Dispose`, simply forwarding the calls to the inner client instance. A derived type may then override just the methods it needs to in order to augment the behavior, delegating to the base implementation in order to forward the call along to the wrapped client. This setup is useful for creating flexible and modular chat clients that can be easily extended and composed. -Here is an example class derived from `DelegatingChatClient` to provide logging functionality: +Here is an example class derived from `DelegatingChatClient` to provide rate limiting functionality, utilizing the [System.Threading.RateLimiting](https://www.nuget.org/packages/System.Threading.RateLimiting) library: ```csharp using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using System.Runtime.CompilerServices; -using System.Text.Json; +using System.Threading.RateLimiting; -public sealed class LoggingChatClient(IChatClient innerClient, ILogger? logger = null) : - DelegatingChatClient(innerClient) +public sealed class RateLimitingChatClient(IChatClient innerClient, RateLimiter rateLimiter) : DelegatingChatClient(innerClient) { public override async Task CompleteAsync( - IList chatMessages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) { - logger?.LogTrace("Request: {Messages}", chatMessages); - var chatCompletion = await base.CompleteAsync(chatMessages, options, cancellationToken); - logger?.LogTrace("Response: {Completion}", JsonSerializer.Serialize(chatCompletion)); - return chatCompletion; + using var lease = await rateLimiter.AcquireAsync(permitCount: 1, cancellationToken).ConfigureAwait(false); + if (!lease.IsAcquired) + throw new InvalidOperationException("Unable to acquire lease."); + + return await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); } public override async IAsyncEnumerable CompleteStreamingAsync( - IList chatMessages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - logger?.LogTrace("Request: {Messages}", chatMessages); - await foreach (var update in base.CompleteStreamingAsync(chatMessages, options, cancellationToken)) - { - logger?.LogTrace("Response Update: {Update}", JsonSerializer.Serialize(update)); + using var lease = await rateLimiter.AcquireAsync(permitCount: 1, cancellationToken).ConfigureAwait(false); + if (!lease.IsAcquired) + throw new InvalidOperationException("Unable to acquire lease."); + + await foreach (var update in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) yield return update; - } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + rateLimiter.Dispose(); + + base.Dispose(disposing); } } ``` @@ -302,13 +304,13 @@ This can then be composed as with other `IChatClient` implementations. ```csharp using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; +using System.Threading.RateLimiting; -var client = new LoggingChatClient( +var client = new RateLimitingChatClient( new SampleChatClient(new Uri("http://localhost"), "test"), - LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)).CreateLogger("AI")); + new ConcurrencyLimiter(new() { PermitLimit = 1, QueueLimit = int.MaxValue })); -await client.CompleteAsync("Hello, world!"); +await client.CompleteAsync("What color is the sky?"); ``` #### Dependency Injection @@ -435,35 +437,44 @@ foreach (var embedding in embeddings) Also as with `IChatClient`, `IEmbeddingGenerator` enables building custom middleware that extends the functionality of an `IEmbeddingGenerator`. The `DelegatingEmbeddingGenerator` class is an implementation of the `IEmbeddingGenerator` interface that serves as a base class for creating embedding generators which delegate their operations to another `IEmbeddingGenerator` instance. It allows for chaining multiple generators in any order, passing calls through to an underlying generator. The class provides default implementations for methods such as `GenerateAsync` and `Dispose`, which simply forward the calls to the inner generator instance, enabling flexible and modular embedding generation. -Here is an example implementation of such a delegating embedding generator that logs embedding generation requests: +Here is an example implementation of such a delegating embedding generator that rate limits embedding generation requests: ```csharp using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; +using System.Threading.RateLimiting; -public class LoggingEmbeddingGenerator(IEmbeddingGenerator> innerGenerator, ILogger? logger = null) : +public class RateLimitingEmbeddingGenerator(IEmbeddingGenerator> innerGenerator, RateLimiter rateLimiter) : DelegatingEmbeddingGenerator>(innerGenerator) { - public override Task>> GenerateAsync( - IEnumerable values, - EmbeddingGenerationOptions? options = null, - CancellationToken cancellationToken = default) + public override async Task>> GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) { - logger?.LogInformation("Generating embeddings for {Count} values", values.Count()); - return base.GenerateAsync(values, options, cancellationToken); + using var lease = await rateLimiter.AcquireAsync(permitCount: 1, cancellationToken).ConfigureAwait(false); + if (!lease.IsAcquired) + throw new InvalidOperationException("Unable to acquire lease."); + + return await base.GenerateAsync(values, options, cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + rateLimiter.Dispose(); + + base.Dispose(disposing); } } ``` -This can then be layered around an arbitrary `IEmbeddingGenerator>` to log all embedding generation operations performed. +This can then be layered around an arbitrary `IEmbeddingGenerator>` to rate limit all embedding generation operations performed. ```csharp using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; +using System.Threading.RateLimiting; IEmbeddingGenerator> generator = - new LoggingEmbeddingGenerator( + new RateLimitingEmbeddingGenerator( new SampleEmbeddingGenerator(new Uri("http://coolsite.ai"), "my-custom-model"), - LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)).CreateLogger("AI")); + new ConcurrencyLimiter(new() { PermitLimit = 1, QueueLimit = int.MaxValue })); foreach (var embedding in await generator.GenerateAsync(["What is AI?", "What is .NET?"])) { From ef606f2e1f40c69d4289d15e37c05e07dc20fd14 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 16 Oct 2024 10:59:06 -0400 Subject: [PATCH 037/190] Add AdditionalPropertiesDictionary.TryGetValue (#5528) * Add AdditionalPropertiesDictionary.TryGetValue * Update comments --- eng/MSBuild/Shared.props | 4 -- .../AdditionalPropertiesDictionary.cs | 57 ++++++++++++++- ...icrosoft.Extensions.AI.Abstractions.csproj | 1 - ...soft.Extensions.AI.AzureAIInference.csproj | 1 - .../Microsoft.Extensions.AI.Ollama.csproj | 1 - .../OllamaChatClient.cs | 3 +- .../OllamaEmbeddingGenerator.cs | 5 +- .../Microsoft.Extensions.AI.OpenAI.csproj | 1 - .../OpenAIChatClient.cs | 20 +++--- .../OpenAIEmbeddingGenerator.cs | 5 +- .../ChatCompletion/OpenTelemetryChatClient.cs | 3 +- .../Microsoft.Extensions.AI.csproj | 1 - .../CollectionExtensions.cs | 72 ------------------- src/Shared/CollectionExtensions/README.md | 11 --- .../AdditionalPropertiesDictionaryTests.cs | 46 ++++++++++++ .../OpenAIChatClientTests.cs | 6 +- 16 files changed, 119 insertions(+), 118 deletions(-) delete mode 100644 src/Shared/CollectionExtensions/CollectionExtensions.cs delete mode 100644 src/Shared/CollectionExtensions/README.md diff --git a/eng/MSBuild/Shared.props b/eng/MSBuild/Shared.props index 7c5ac8424e0..a68b0e4298f 100644 --- a/eng/MSBuild/Shared.props +++ b/eng/MSBuild/Shared.props @@ -1,8 +1,4 @@ - - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs index 28b513cda4a..616ad284198 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs @@ -4,6 +4,8 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; namespace Microsoft.Extensions.AI; @@ -45,7 +47,7 @@ public AdditionalPropertiesDictionary(IEnumerable> /// A shallow clone of the properties dictionary. The instance will not be the same as the current instance, /// but it will contain all of the same key-value pairs. /// - public AdditionalPropertiesDictionary Clone() => new AdditionalPropertiesDictionary(_dictionary); + public AdditionalPropertiesDictionary Clone() => new(_dictionary); /// public object? this[string key] @@ -94,6 +96,9 @@ public object? this[string key] /// public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); + /// + IEnumerator IEnumerable.GetEnumerator() => _dictionary.GetEnumerator(); + /// public bool Remove(string key) => _dictionary.Remove(key); @@ -103,6 +108,52 @@ public object? this[string key] /// public bool TryGetValue(string key, out object? value) => _dictionary.TryGetValue(key, out value); - /// - IEnumerator IEnumerable.GetEnumerator() => _dictionary.GetEnumerator(); + /// Attempts to extract a typed value from the dictionary. + /// Specifies the type of the value to be retrieved. + /// The key to locate. + /// + /// The value retrieved from the dictionary, if found and successfully converted to the requested type; + /// otherwise, the default value of . + /// + /// + /// if a non- value was found for + /// in the dictionary and converted to the requested type; otherwise, . + /// + /// + /// If a non- is found for the key in the dictionary, but the value is not of the requested type but is + /// an object, the method will attempt to convert the object to the requested type. + /// + public bool TryGetValue(string key, [NotNullWhen(true)] out T? value) + { + if (TryGetValue(key, out object? obj)) + { + switch (obj) + { + case T t: + // The object is already of the requested type. Return it. + value = t; + return true; + + case IConvertible: + // The object is convertible; try to convert it to the requested type. Unfortunately, there's no + // convenient way to do this that avoids exceptions and that doesn't involve a ton of boilerplate, + // so we only try when the source object is at least an IConvertible, which is what ChangeType uses. + try + { + value = (T)Convert.ChangeType(obj, typeof(T), CultureInfo.InvariantCulture); + return true; + } + catch (Exception e) when (e is ArgumentException or FormatException or InvalidCastException or OverflowException) + { + // Ignore known failure modes. + } + + break; + } + } + + // Unable to find the value or convert it to the requested type. + value = default; + return false; + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index 4aa2ab89d73..2906a24e0ce 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -19,7 +19,6 @@ - true true true true 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 d1f802ace8a..622495618c6 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 @@ -20,7 +20,6 @@ true - true true true true diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj index ac0abe33c10..0a562ead7d0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj @@ -21,7 +21,6 @@ true true - true true true true diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 61827d45cc9..6aee8978ac4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -11,7 +11,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Shared.Collections; using Microsoft.Shared.Diagnostics; #pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) @@ -298,7 +297,7 @@ private OllamaChatRequest ToOllamaChatRequest(IList chatMessages, C void TransferMetadataValue(string propertyName, Action setOption) { - if (options.AdditionalProperties?.TryGetConvertedValue(propertyName, out T? t) is true) + if (options.AdditionalProperties?.TryGetValue(propertyName, out T? t) is true) { request.Options ??= new(); setOption(request.Options, t); diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs index b0ecf08895c..6a34a2ff811 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs @@ -8,7 +8,6 @@ using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Shared.Collections; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -75,12 +74,12 @@ public async Task>> GenerateAsync(IEnumerab if (options?.AdditionalProperties is { } requestProps) { - if (requestProps.TryGetConvertedValue("keep_alive", out long keepAlive)) + if (requestProps.TryGetValue("keep_alive", out long keepAlive)) { request.KeepAlive = keepAlive; } - if (requestProps.TryGetConvertedValue("truncate", out bool truncate)) + if (requestProps.TryGetValue("truncate", out bool truncate)) { request.Truncate = truncate; } 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 1efedb13f11..3426263d157 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 @@ -19,7 +19,6 @@ - true true true diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index f92fcfa3bc9..695a6fc620b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -11,7 +11,6 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Microsoft.Shared.Collections; using Microsoft.Shared.Diagnostics; using OpenAI; using OpenAI.Chat; @@ -410,17 +409,17 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) if (options.AdditionalProperties is { Count: > 0 } additionalProperties) { - if (additionalProperties.TryGetConvertedValue(nameof(result.EndUserId), out string? endUserId)) + if (additionalProperties.TryGetValue(nameof(result.EndUserId), out string? endUserId)) { result.EndUserId = endUserId; } - if (additionalProperties.TryGetConvertedValue(nameof(result.IncludeLogProbabilities), out bool includeLogProbabilities)) + if (additionalProperties.TryGetValue(nameof(result.IncludeLogProbabilities), out bool includeLogProbabilities)) { result.IncludeLogProbabilities = includeLogProbabilities; } - if (additionalProperties.TryGetConvertedValue(nameof(result.LogitBiases), out IDictionary? logitBiases)) + if (additionalProperties.TryGetValue(nameof(result.LogitBiases), out IDictionary? logitBiases)) { foreach (KeyValuePair kvp in logitBiases!) { @@ -428,19 +427,19 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) } } - if (additionalProperties.TryGetConvertedValue(nameof(result.AllowParallelToolCalls), out bool allowParallelToolCalls)) + if (additionalProperties.TryGetValue(nameof(result.AllowParallelToolCalls), out bool allowParallelToolCalls)) { result.AllowParallelToolCalls = allowParallelToolCalls; } #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. - if (additionalProperties.TryGetConvertedValue(nameof(result.Seed), out long seed)) + if (additionalProperties.TryGetValue(nameof(result.Seed), out long seed)) { result.Seed = seed; } #pragma warning restore OPENAI001 - if (additionalProperties.TryGetConvertedValue(nameof(result.TopLogProbabilityCount), out int topLogProbabilityCountInt)) + if (additionalProperties.TryGetValue(nameof(result.TopLogProbabilityCount), out int topLogProbabilityCountInt)) { result.TopLogProbabilityCount = topLogProbabilityCountInt; } @@ -488,7 +487,10 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) /// Converts an Extensions function to an OpenAI chat tool. private ChatTool ToOpenAIChatTool(AIFunction aiFunction) { - _ = aiFunction.Metadata.AdditionalProperties.TryGetConvertedValue("Strict", out bool strict); + bool? strict = + aiFunction.Metadata.AdditionalProperties.TryGetValue("Strict", out object? strictObj) && + strictObj is bool strictValue ? + strictValue : null; BinaryData resultParameters = OpenAIChatToolJson.ZeroFunctionParametersSchema; @@ -643,7 +645,7 @@ private sealed class OpenAIChatToolJson new(toolCalls.Values) { ParticipantName = input.AuthorName } : new(input.Text) { ParticipantName = input.AuthorName }; - if (input.AdditionalProperties?.TryGetConvertedValue(nameof(message.Refusal), out string? refusal) is true) + if (input.AdditionalProperties?.TryGetValue(nameof(message.Refusal), out string? refusal) is true) { message.Refusal = refusal; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index e91394befdd..084e235df47 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -7,7 +7,6 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; -using Microsoft.Shared.Collections; using Microsoft.Shared.Diagnostics; using OpenAI; using OpenAI.Embeddings; @@ -144,12 +143,12 @@ void IDisposable.Dispose() if (options?.AdditionalProperties is { Count: > 0 } additionalProperties) { // Allow per-instance dimensions to be overridden by a per-call property - if (additionalProperties.TryGetConvertedValue(nameof(openAIOptions.Dimensions), out int? dimensions)) + if (additionalProperties.TryGetValue(nameof(openAIOptions.Dimensions), out int? dimensions)) { openAIOptions.Dimensions = dimensions; } - if (additionalProperties.TryGetConvertedValue(nameof(openAIOptions.EndUserId), out string? endUserId)) + if (additionalProperties.TryGetValue(nameof(openAIOptions.EndUserId), out string? endUserId)) { openAIOptions.EndUserId = endUserId; } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index 13e2d1229dd..5129ec9d160 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -11,7 +11,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Shared.Collections; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -308,7 +307,7 @@ private static Dictionary> OrganizeStre _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.Temperature, temperature); } - if (options.AdditionalProperties?.TryGetConvertedValue("top_k", out double topK) is true) + if (options.AdditionalProperties?.TryGetValue("top_k", out double topK) is true) { _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.TopK, topK); } diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 31beec15fe6..bda7af37a5a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -19,7 +19,6 @@ - true true false diff --git a/src/Shared/CollectionExtensions/CollectionExtensions.cs b/src/Shared/CollectionExtensions/CollectionExtensions.cs deleted file mode 100644 index 33196e6e771..00000000000 --- a/src/Shared/CollectionExtensions/CollectionExtensions.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.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; - -#pragma warning disable S108 // Nested blocks of code should not be left empty -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable SA1501 // Statement should not be on a single line - -#pragma warning disable CA1716 -namespace Microsoft.Shared.Collections; -#pragma warning restore CA1716 - -/// -/// Utilities to augment the basic collection types. -/// -#if !SHARED_PROJECT -[ExcludeFromCodeCoverage] -#endif - -internal static class CollectionExtensions -{ - /// Attempts to extract a typed value from the dictionary. - /// The dictionary to query. - /// The key to locate. - /// The value retrieved from the dictionary, if found; otherwise, default. - /// True if the value was found and converted to the requested type; otherwise, false. - /// - /// If a value is found for the key in the dictionary, but the value is not of the requested type but is - /// an object, the method will attempt to convert the object to the requested type. - /// is employed because these methods are primarily intended for use with primitives. - /// - public static bool TryGetConvertedValue(this IReadOnlyDictionary? input, string key, [NotNullWhen(true)] out T? value) - { - object? valueObject = null; - _ = input?.TryGetValue(key, out valueObject); - return TryConvertValue(valueObject, out value); - } - - private static bool TryConvertValue(object? obj, [NotNullWhen(true)] out T? value) - { - switch (obj) - { - case T t: - // The object is already of the requested type. Return it. - value = t; - return true; - - case IConvertible: - // The object is convertible; try to convert it to the requested type. Unfortunately, there's no - // convenient way to do this that avoids exceptions and that doesn't involve a ton of boilerplate, - // so we only try when the source object is at least an IConvertible, which is what ChangeType uses. - try - { - value = (T)Convert.ChangeType(obj, typeof(T), CultureInfo.InvariantCulture); - return true; - } - catch (ArgumentException) { } - catch (InvalidCastException) { } - catch (FormatException) { } - catch (OverflowException) { } - break; - } - - // Unable to convert the object to the requested type. Fail. - value = default; - return false; - } -} diff --git a/src/Shared/CollectionExtensions/README.md b/src/Shared/CollectionExtensions/README.md deleted file mode 100644 index a732b7c36d4..00000000000 --- a/src/Shared/CollectionExtensions/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Collection Extensions - -`TryGetTypedValue` performs a ``TryGetValue` on a dictionary and then attempts to cast the value to the specified type. If the value is not of the specified type, false is returned. - -To use this in your project, add the following to your `.csproj` file: - -```xml - - true - -``` diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs index e71b2f431e8..a9a544c8ca8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.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; using System.Collections.Generic; using Xunit; @@ -44,4 +45,49 @@ public void Comparer_OrdinalIgnoreCase() Assert.Equal("value5", d["Key3"]); Assert.Equal("value5", d["KEy3"]); } + + [Fact] + public void TryGetValue_Typed_ExtractsExpectedValue() + { + AssertFound(42, 42L); + AssertFound(42, 42.0); + AssertFound(42, 42f); + AssertFound(42, true); + AssertFound(42, "42"); + AssertFound(42, (object)42); + AssertFound(42.0, 42f); + AssertFound(42f, 42.0); + AssertFound(42m, 42.0f); + AssertFound(42L, 42); + AssertFound("42", "42"); + AssertFound("42", 42); + AssertFound("42", 42L); + AssertFound("42", 42.0); + AssertFound("42", 42f); + AssertFound(true, 1); + AssertFound(false, 0); + + AssertNotFound(42); + AssertNotFound(42); + + static void AssertFound(T1 input, T2 expected) + { + AdditionalPropertiesDictionary d = []; + d["key"] = input; + + Assert.True(d.TryGetValue("key", out T2? value)); + Assert.Equal(expected, value); + + Assert.False(d.TryGetValue("key2", out value)); + Assert.Equal(default, value); + } + + static void AssertNotFound(T1 input) + { + AdditionalPropertiesDictionary d = []; + d["key"] = input; + Assert.False(d.TryGetValue("key", out T2? value)); + Assert.Equal(default(T2), value); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index f19a19f3ce8..947deb2674d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -414,8 +414,7 @@ public async Task FunctionCallContent_NonStreaming() "type": "string" } } - }, - "strict": false + } } } ], @@ -529,8 +528,7 @@ public async Task FunctionCallContent_Streaming() "type": "string" } } - }, - "strict": false + } } } ], From ea3b0dbe6a4c50acf90e3a4e47fadce5a604a9c1 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Wed, 16 Oct 2024 22:12:37 +0000 Subject: [PATCH 038/190] Update dependencies from https://github.com/dotnet/arcade build 20241016.2 (#5530) [main] Update dependencies from dotnet/arcade --- eng/Version.Details.xml | 8 ++++---- .../core-templates/steps/get-delegation-sas.yml | 11 ++++++++++- global.json | 4 ++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 5fccc8eaa2b..8dec69a1b07 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -186,13 +186,13 @@ - + https://github.com/dotnet/arcade - 05c72bb3c9b38138276a8029017f2ef905dcc7fa + 3c393bbd85ae16ddddba20d0b75035b0c6f1a52d - + https://github.com/dotnet/arcade - 05c72bb3c9b38138276a8029017f2ef905dcc7fa + 3c393bbd85ae16ddddba20d0b75035b0c6f1a52d diff --git a/eng/common/core-templates/steps/get-delegation-sas.yml b/eng/common/core-templates/steps/get-delegation-sas.yml index d2901470a7f..9db5617ea7d 100644 --- a/eng/common/core-templates/steps/get-delegation-sas.yml +++ b/eng/common/core-templates/steps/get-delegation-sas.yml @@ -31,7 +31,16 @@ steps: # Calculate the expiration of the SAS token and convert to UTC $expiry = (Get-Date).AddHours(${{ parameters.expiryInHours }}).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - $sas = az storage container generate-sas --account-name ${{ parameters.storageAccount }} --name ${{ parameters.container }} --permissions ${{ parameters.permissions }} --expiry $expiry --auth-mode login --as-user -o tsv + # Temporarily work around a helix issue where SAS tokens with / in them will cause incorrect downloads + # of correlation payloads. https://github.com/dotnet/dnceng/issues/3484 + $sas = "" + do { + $sas = az storage container generate-sas --account-name ${{ parameters.storageAccount }} --name ${{ parameters.container }} --permissions ${{ parameters.permissions }} --expiry $expiry --auth-mode login --as-user -o tsv + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to generate SAS token." + exit 1 + } + } while($sas.IndexOf('/') -ne -1) if ($LASTEXITCODE -ne 0) { Write-Error "Failed to generate SAS token." diff --git a/global.json b/global.json index 23bae65d43c..8cb95c3b459 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.24509.3", - "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.24509.3" + "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.24516.2", + "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.24516.2" } } From d894f98ff87ba5dc994101615369e9254d034822 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 17 Oct 2024 17:26:04 +0100 Subject: [PATCH 039/190] Expose an `AIJsonUtilities` class in M.E.AI and lower M.E.AI.Abstractions to STJv8 (#5513) * Expose FunctionCallUtilities class. * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallUtilities.cs Co-authored-by: Stephen Toub * Remove function call formatting helpers. * Extract JSON schema inference settings into a separate options class. * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/JsonSchemaInferenceOptions.cs Co-authored-by: Stephen Toub * Address feedback * Return FunctionCallContent in parser helpers. * Address feedback * Update src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs Co-authored-by: Stephen Toub * Refactor to AIJsonUtilities class. * Move all utilities to M.E.AI and downgrade STJ version to 8 for M.E.AI.Abstractions. --------- Co-authored-by: Stephen Toub --- eng/Versions.props | 1 + .../Contents/FunctionCallContent.cs | 40 ++ .../Contents/FunctionCallHelpers.cs | 395 ------------------ ...icrosoft.Extensions.AI.Abstractions.csproj | 4 +- .../AzureAIInferenceChatClient.cs | 44 +- ...soft.Extensions.AI.AzureAIInference.csproj | 6 +- .../JsonContext.cs | 4 + .../Microsoft.Extensions.AI.Ollama.csproj | 4 - .../OllamaChatClient.cs | 17 +- .../Microsoft.Extensions.AI.OpenAI.csproj | 4 - .../OpenAIChatClient.cs | 50 ++- .../ChatClientStructuredOutputExtensions.cs | 38 +- .../DistributedCachingChatClient.cs | 2 +- .../ChatCompletion/LoggingChatClient.cs | 2 +- .../ChatCompletion/OpenTelemetryChatClient.cs | 2 +- .../DistributedCachingEmbeddingGenerator.cs | 2 +- .../Embeddings/LoggingEmbeddingGenerator.cs | 2 +- .../Functions/AIFunctionFactory.Utilities.cs | 33 ++ .../Functions/AIFunctionFactory.cs | 13 +- .../AIFunctionFactoryCreateOptions.cs | 2 +- .../Microsoft.Extensions.AI.csproj | 6 +- .../Utilities/AIJsonSchemaCreateOptions.cs | 30 ++ .../AIJsonUtilities.Defaults.cs} | 9 +- .../Utilities/AIJsonUtilities.Schema.cs | 348 +++++++++++++++ .../AIJsonUtilitiesTests.cs | 145 +++++++ .../Contents/FunctionCallContentTests..cs | 76 ++++ 26 files changed, 782 insertions(+), 497 deletions(-) delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.Utilities.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonSchemaCreateOptions.cs rename src/Libraries/Microsoft.Extensions.AI/{JsonDefaults.cs => Utilities/AIJsonUtilities.Defaults.cs} (90%) create mode 100644 src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs diff --git a/eng/Versions.props b/eng/Versions.props index 3732d8e1434..5e156780a6e 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -62,6 +62,7 @@ 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 + 8.0.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24474.3 diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index b50fc531179..f106d9b615c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -56,6 +56,46 @@ public FunctionCallContent(string callId, string name, IDictionary + /// Creates a new instance of parsing arguments using a specified encoding and parser. + /// + /// The encoding format from which to parse function call arguments. + /// The input arguments encoded in . + /// The function call ID. + /// The function name. + /// The parsing implementation converting the encoding to a dictionary of arguments. + /// Filters potential parsing exceptions that should be caught and included in the result. + /// A new instance of containing the parse result. + public static FunctionCallContent CreateFromParsedArguments( + TEncoding encodedArguments, + string callId, + string name, + Func?> argumentParser, + Func? exceptionFilter = null) + { + _ = Throw.IfNull(callId); + _ = Throw.IfNull(name); + _ = Throw.IfNull(encodedArguments); + _ = Throw.IfNull(argumentParser); + + IDictionary? arguments = null; + Exception? parsingException = null; + + try + { + arguments = argumentParser(encodedArguments); + } + catch (Exception ex) when (exceptionFilter is null || exceptionFilter(ex)) + { + parsingException = new InvalidOperationException("Error parsing function call arguments.", ex); + } + + return new FunctionCallContent(callId, name, arguments) + { + Exception = parsingException + }; + } + /// Gets a string representing this instance to display in the debugger. private string DebuggerDisplay { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs deleted file mode 100644 index e9524b91ab1..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs +++ /dev/null @@ -1,395 +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.Concurrent; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Schema; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Microsoft.Shared.Diagnostics; - -using FunctionParameterKey = (System.Type? Type, string ParameterName, string? Description, bool HasDefaultValue, object? DefaultValue); - -namespace Microsoft.Extensions.AI; - -/// Provides a collection of static utility methods for marshalling JSON data in function calling. -internal static partial class FunctionCallHelpers -{ - /// Soft limit for how many items should be stored in the dictionaries in . - private const int CacheSoftLimit = 4096; - - /// Caches of generated schemas for each that's employed. - private static readonly ConditionalWeakTable> _schemaCaches = new(); - - /// Gets a JSON schema accepting all values. - private static JsonElement TrueJsonSchema { get; } = ParseJsonElement("true"u8); - - /// Gets a JSON schema only accepting null values. - private static JsonElement NullJsonSchema { get; } = ParseJsonElement("""{"type":"null"}"""u8); - - /// Parses a JSON object into a dictionary of objects encoded as . - /// A JSON object containing the parameters. - /// If the parsing fails, the resulting exception. - /// The parsed dictionary of objects encoded as . - public static Dictionary? ParseFunctionCallArguments(string json, out Exception? parsingException) - { - _ = Throw.IfNull(json); - - parsingException = null; - try - { - return JsonSerializer.Deserialize(json, FunctionCallHelperContext.Default.DictionaryStringObject); - } - catch (JsonException ex) - { - parsingException = new InvalidOperationException($"Function call arguments contained invalid JSON: {json}", ex); - return null; - } - } - - /// Parses a JSON object into a dictionary of objects encoded as . - /// A UTF-8 encoded JSON object containing the parameters. - /// If the parsing fails, the resulting exception. - /// The parsed dictionary of objects encoded as . - public static Dictionary? ParseFunctionCallArguments(ReadOnlySpan utf8Json, out Exception? parsingException) - { - parsingException = null; - try - { - return JsonSerializer.Deserialize(utf8Json, FunctionCallHelperContext.Default.DictionaryStringObject); - } - catch (JsonException ex) - { - parsingException = new InvalidOperationException($"Function call arguments contained invalid JSON: {Encoding.UTF8.GetString(utf8Json.ToArray())}", ex); - return null; - } - } - - /// - /// Serializes a dictionary of function parameters into a JSON string. - /// - /// The dictionary of parameters. - /// A governing serialization. - /// A JSON encoding of the parameters. - public static string FormatFunctionParametersAsJson(IDictionary? parameters, JsonSerializerOptions? options = null) - { - // Fall back to the built-in context since in most cases the return value is JsonElement or JsonNode. - options ??= FunctionCallHelperContext.Default.Options; - options.MakeReadOnly(); - return JsonSerializer.Serialize(parameters, options.GetTypeInfo(typeof(IDictionary))); - } - - /// - /// Serializes a dictionary of function parameters into a . - /// - /// The dictionary of parameters. - /// A governing serialization. - /// A JSON encoding of the parameters. - public static JsonElement FormatFunctionParametersAsJsonElement(IDictionary? parameters, JsonSerializerOptions? options = null) - { - // Fall back to the built-in context since in most cases the return value is JsonElement or JsonNode. - options ??= FunctionCallHelperContext.Default.Options; - options.MakeReadOnly(); - return JsonSerializer.SerializeToElement(parameters, options.GetTypeInfo(typeof(IDictionary))); - } - - /// - /// Serializes a .NET function return parameter to a JSON string. - /// - /// The result value to be serialized. - /// A governing serialization. - /// A JSON encoding of the parameter. - public static string FormatFunctionResultAsJson(object? result, JsonSerializerOptions? options = null) - { - // Fall back to the built-in context since in most cases the return value is JsonElement or JsonNode. - options ??= FunctionCallHelperContext.Default.Options; - options.MakeReadOnly(); - return JsonSerializer.Serialize(result, options.GetTypeInfo(typeof(object))); - } - - /// - /// Serializes a .NET function return parameter to a JSON element. - /// - /// The result value to be serialized. - /// A governing serialization. - /// A JSON encoding of the parameter. - public static JsonElement FormatFunctionResultAsJsonElement(object? result, JsonSerializerOptions? options = null) - { - // Fall back to the built-in context since in most cases the return value is JsonElement or JsonNode. - options ??= FunctionCallHelperContext.Default.Options; - options.MakeReadOnly(); - return JsonSerializer.SerializeToElement(result, options.GetTypeInfo(typeof(object))); - } - - /// - /// Determines a JSON schema for the provided parameter metadata. - /// - /// The parameter metadata from which to infer the schema. - /// The containing function metadata. - /// The global governing serialization. - /// A JSON schema document encoded as a . - public static JsonElement InferParameterJsonSchema( - AIFunctionParameterMetadata parameterMetadata, - AIFunctionMetadata functionMetadata, - JsonSerializerOptions? options) - { - options ??= functionMetadata.JsonSerializerOptions; - - if (ReferenceEquals(options, functionMetadata.JsonSerializerOptions) && - parameterMetadata.Schema is JsonElement schema) - { - // If the resolved options matches that of the function metadata, - // we can just return the precomputed JSON schema value. - return schema; - } - - if (options is null) - { - return TrueJsonSchema; - } - - return InferParameterJsonSchema( - parameterMetadata.ParameterType, - parameterMetadata.Name, - parameterMetadata.Description, - parameterMetadata.HasDefaultValue, - parameterMetadata.DefaultValue, - options); - } - - /// - /// Determines a JSON schema for the provided parameter metadata. - /// - /// The type of the parameter. - /// The name of the parameter. - /// The description of the parameter. - /// Whether the parameter is optional. - /// The default value of the optional parameter, if applicable. - /// The options used to extract the schema from the specified type. - /// A JSON schema document encoded as a . - public static JsonElement InferParameterJsonSchema( - Type? type, - string name, - string? description, - bool hasDefaultValue, - object? defaultValue, - JsonSerializerOptions options) - { - _ = Throw.IfNull(name); - _ = Throw.IfNull(options); - - options.MakeReadOnly(); - - try - { - ConcurrentDictionary cache = _schemaCaches.GetOrCreateValue(options); - FunctionParameterKey key = new(type, name, description, hasDefaultValue, defaultValue); - - if (cache.Count > CacheSoftLimit) - { - return GetJsonSchemaCore(options, key); - } - - return cache.GetOrAdd( - key: key, -#if NET - valueFactory: static (key, options) => GetJsonSchemaCore(options, key), - factoryArgument: options); -#else - valueFactory: key => GetJsonSchemaCore(options, key)); -#endif - } - catch (ArgumentException) - { - // Invalid type; ignore, and leave schema as null. - // This should be exceedingly rare, as we checked for all known category of - // problematic types above. If it becomes more common that schema creation - // could fail expensively, we'll want to track whether inference was already - // attempted and avoid doing so on subsequent accesses if it was. - return TrueJsonSchema; - } - } - - /// Infers a JSON schema from the return parameter. - /// The type of the return parameter. - /// The options used to extract the schema from the specified type. - /// A representing the schema. - public static JsonElement InferReturnParameterJsonSchema(Type? type, JsonSerializerOptions options) - { - _ = Throw.IfNull(options); - - options.MakeReadOnly(); - - // If there's no type, just return a schema that allows anything. - if (type is null) - { - return TrueJsonSchema; - } - - if (type == typeof(void)) - { - return NullJsonSchema; - } - - JsonNode node = options.GetJsonSchemaAsNode(type); - return JsonSerializer.SerializeToElement(node, FunctionCallHelperContext.Default.JsonNode); - } - - private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, FunctionParameterKey key) - { - _ = Throw.IfNull(options); - - if (options.ReferenceHandler == ReferenceHandler.Preserve) - { - throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); - } - - if (key.Type is null) - { - // For parameters without a type generate a rudimentary schema with available metadata. - - JsonObject schemaObj = []; - if (key.Description is not null) - { - schemaObj["description"] = key.Description; - } - - if (key.HasDefaultValue) - { - JsonNode? defaultValueNode = key.DefaultValue is { } defaultValue - ? JsonSerializer.Serialize(defaultValue, options.GetTypeInfo(defaultValue.GetType())) - : null; - - schemaObj["default"] = defaultValueNode; - } - - return JsonSerializer.SerializeToElement(schemaObj, FunctionCallHelperContext.Default.JsonNode); - } - - options.MakeReadOnly(); - - JsonSchemaExporterOptions exporterOptions = new() - { - TreatNullObliviousAsNonNullable = true, - TransformSchemaNode = TransformSchemaNode, - }; - - JsonNode node = options.GetJsonSchemaAsNode(key.Type, exporterOptions); - return JsonSerializer.SerializeToElement(node, FunctionCallHelperContext.Default.JsonNode); - - JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) - { - const string DescriptionPropertyName = "description"; - const string NotPropertyName = "not"; - const string PropertiesPropertyName = "properties"; - const string DefaultPropertyName = "default"; - const string RefPropertyName = "$ref"; - - // Find the first DescriptionAttribute, starting first from the property, then the parameter, and finally the type itself. - Type descAttrType = typeof(DescriptionAttribute); - var descriptionAttribute = - GetAttrs(descAttrType, ctx.PropertyInfo?.AttributeProvider)?.FirstOrDefault() ?? - GetAttrs(descAttrType, ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider)?.FirstOrDefault() ?? - GetAttrs(descAttrType, ctx.TypeInfo.Type)?.FirstOrDefault(); - - if (descriptionAttribute is DescriptionAttribute attr) - { - ConvertSchemaToObject(ref schema).Insert(0, DescriptionPropertyName, (JsonNode)attr.Description); - } - - // If the type is recursive, the resulting schema will contain a $ref to the type itself. - // As JSON pointer doesn't support relative paths, we need to fix up such paths to accommodate - // the fact that they're being nested inside of a higher-level schema. - if (schema is JsonObject refObj && refObj.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}/{key.ParameterName}" - : $"#/{PropertiesPropertyName}/{key.ParameterName}/{refUri.AsMemory("#/".Length)}"; - - refObj[RefPropertyName] = (JsonNode)refUri; - } - - if (ctx.Path.IsEmpty) - { - // We are at the root-level schema node, append parameter-specific metadata - - if (!string.IsNullOrWhiteSpace(key.Description)) - { - ConvertSchemaToObject(ref schema).Insert(0, DescriptionPropertyName, (JsonNode)key.Description!); - } - - if (key.HasDefaultValue) - { - JsonNode? defaultValue = JsonSerializer.Serialize(key.DefaultValue, options.GetTypeInfo(typeof(object))); - ConvertSchemaToObject(ref schema)[DefaultPropertyName] = defaultValue; - } - } - - return schema; - - static object[]? GetAttrs(Type attrType, ICustomAttributeProvider? provider) => - provider?.GetCustomAttributes(attrType, inherit: false); - - static JsonObject ConvertSchemaToObject(ref JsonNode schema) - { - JsonObject obj; - JsonValueKind kind = schema.GetValueKind(); - switch (kind) - { - case JsonValueKind.Object: - return (JsonObject)schema; - - case JsonValueKind.False: - schema = obj = new() { [NotPropertyName] = true }; - return obj; - - default: - Debug.Assert(kind is JsonValueKind.True, $"Invalid schema type: {kind}"); - schema = obj = []; - return obj; - } - } - } - } - - private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) - { - Utf8JsonReader reader = new(utf8Json); - return JsonElement.ParseValue(ref reader); - } - - [JsonSerializable(typeof(Dictionary))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(JsonNode))] - [JsonSerializable(typeof(JsonElement))] - [JsonSerializable(typeof(JsonDocument))] - private sealed partial class FunctionCallHelperContext : JsonSerializerContext; - - /// - /// Remove characters from method name that are valid in metadata but shouldn't be used in a method name. - /// This is primarily intended to remove characters emitted by for compiler-generated method name mangling. - /// - public static string SanitizeMetadataName(string metadataName) => - InvalidNameCharsRegex().Replace(metadataName, "_"); - - /// Regex that flags any character other than ASCII digits or letters or the underscore. -#if NET - [GeneratedRegex("[^0-9A-Za-z_]")] - private static partial Regex InvalidNameCharsRegex(); -#else - private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; - private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); -#endif -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index 2906a24e0ce..b7f8b935b57 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -24,8 +24,8 @@ true - - + + diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index cccd9f04caf..93c467618c3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -16,12 +16,15 @@ #pragma warning disable S1135 // Track uses of "TODO" tags #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; /// An for an Azure AI Inference . public sealed partial class AzureAIInferenceChatClient : IChatClient { + private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; + /// The underlying . private readonly ChatCompletionsClient _chatCompletionsClient; @@ -93,14 +96,11 @@ public async Task CompleteAsync( { if (toolCall is ChatCompletionsFunctionToolCall ftc && !string.IsNullOrWhiteSpace(ftc.Name)) { - Dictionary? arguments = FunctionCallHelpers.ParseFunctionCallArguments(ftc.Arguments, out Exception? parsingException); + FunctionCallContent callContent = ParseCallContentFromJsonString(ftc.Arguments, toolCall.Id, ftc.Name); + callContent.ModelId = response.Model; + callContent.RawRepresentation = toolCall; - returnMessage.Contents.Add(new FunctionCallContent(toolCall.Id, ftc.Name, arguments) - { - ModelId = response.Model, - Exception = parsingException, - RawRepresentation = toolCall - }); + returnMessage.Contents.Add(callContent); } } } @@ -226,15 +226,14 @@ public async IAsyncEnumerable CompleteStreamingAs FunctionCallInfo fci = entry.Value; if (!string.IsNullOrWhiteSpace(fci.Name)) { - var arguments = FunctionCallHelpers.ParseFunctionCallArguments( + FunctionCallContent callContent = ParseCallContentFromJsonString( fci.Arguments?.ToString() ?? string.Empty, - out Exception? parsingException); + fci.CallId!, + fci.Name!); - completionUpdate.Contents.Add(new FunctionCallContent(fci.CallId!, fci.Name!, arguments) - { - ModelId = modelId, - Exception = parsingException - }); + callContent.ModelId = modelId; + + completionUpdate.Contents.Add(callContent); } } @@ -358,7 +357,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, } /// Converts an Extensions function to an AzureAI chat tool. - private ChatCompletionsFunctionToolDefinition ToAzureAIChatTool(AIFunction aiFunction) + private static ChatCompletionsFunctionToolDefinition ToAzureAIChatTool(AIFunction aiFunction) { BinaryData resultParameters = AzureAIChatToolJson.ZeroFunctionParametersSchema; @@ -371,7 +370,7 @@ private ChatCompletionsFunctionToolDefinition ToAzureAIChatTool(AIFunction aiFun { tool.Properties.Add( parameter.Name, - FunctionCallHelpers.InferParameterJsonSchema(parameter, aiFunction.Metadata, ToolCallJsonSerializerOptions)); + parameter.Schema is JsonElement schema ? schema : _defaultParameterSchema); if (parameter.IsRequired) { @@ -428,9 +427,10 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab string? result = resultContent.Result as string; if (result is null && resultContent.Result is not null) { + JsonSerializerOptions options = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; try { - result = FunctionCallHelpers.FormatFunctionResultAsJson(resultContent.Result, ToolCallJsonSerializerOptions); + result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -461,7 +461,8 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab { if (content is FunctionCallContent callRequest && callRequest.CallId is not null && toolCalls?.ContainsKey(callRequest.CallId) is not true) { - string jsonArguments = FunctionCallHelpers.FormatFunctionParametersAsJson(callRequest.Arguments, ToolCallJsonSerializerOptions); + JsonSerializerOptions serializerOptions = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; + string jsonArguments = JsonSerializer.Serialize(callRequest.Arguments, serializerOptions.GetTypeInfo(typeof(IDictionary))); (toolCalls ??= []).Add( callRequest.CallId, new ChatCompletionsFunctionToolCall( @@ -489,7 +490,14 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab } } + private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(json, callId, name, + argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject), + exceptionFilter: static ex => ex is JsonException); + /// Source-generated JSON type information. [JsonSerializable(typeof(AzureAIChatToolJson))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(JsonElement))] private sealed partial class JsonContext : JsonSerializerContext; } 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 622495618c6..bfd0b8ea90b 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 @@ -24,11 +24,7 @@ true true - - - - - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs index 6de0144c7cf..b90a28abb51 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI; @@ -21,4 +23,6 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(OllamaToolCall))] [JsonSerializable(typeof(OllamaEmbeddingRequest))] [JsonSerializable(typeof(OllamaEmbeddingResponse))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(JsonElement))] 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 index 0a562ead7d0..81beb0d7bed 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj @@ -26,10 +26,6 @@ true - - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 6aee8978ac4..dac6f915d83 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -14,12 +14,15 @@ 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 namespace Microsoft.Extensions.AI; /// An for Ollama. public sealed class OllamaChatClient : IChatClient { + private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; + /// The api/chat endpoint URI. private readonly Uri _apiChatEndpoint; @@ -355,6 +358,8 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe break; case FunctionCallContent fcc: + { + JsonSerializerOptions serializerOptions = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; yield return new OllamaChatRequestMessage { Role = "assistant", @@ -362,13 +367,16 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe { CallId = fcc.CallId, Name = fcc.Name, - Arguments = FunctionCallHelpers.FormatFunctionParametersAsJsonElement(fcc.Arguments, ToolCallJsonSerializerOptions), + Arguments = JsonSerializer.SerializeToElement(fcc.Arguments, serializerOptions.GetTypeInfo(typeof(IDictionary))), }, JsonContext.Default.OllamaFunctionCallContent) }; break; + } case FunctionResultContent frc: - JsonElement jsonResult = FunctionCallHelpers.FormatFunctionResultAsJsonElement(frc.Result, ToolCallJsonSerializerOptions); + { + JsonSerializerOptions serializerOptions = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; + JsonElement jsonResult = JsonSerializer.SerializeToElement(frc.Result, serializerOptions.GetTypeInfo(typeof(object))); yield return new OllamaChatRequestMessage { Role = "tool", @@ -379,6 +387,7 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe }, JsonContext.Default.OllamaFunctionResultContent) }; break; + } } } @@ -388,7 +397,7 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe } } - private OllamaTool ToOllamaTool(AIFunction function) => new() + private static OllamaTool ToOllamaTool(AIFunction function) => new() { Type = "function", Function = new OllamaFunctionTool @@ -399,7 +408,7 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe { Properties = function.Metadata.Parameters.ToDictionary( p => p.Name, - p => FunctionCallHelpers.InferParameterJsonSchema(p, function.Metadata, ToolCallJsonSerializerOptions)), + p => p.Schema is JsonElement e ? e : _defaultParameterSchema), Required = function.Metadata.Parameters.Where(p => p.IsRequired).Select(p => p.Name).ToList(), }, } 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 3426263d157..327f0e6f692 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 @@ -23,10 +23,6 @@ true - - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 695a6fc620b..b00e4b52d7f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -17,12 +17,15 @@ #pragma warning disable S1135 // Track uses of "TODO" tags #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; /// An for an OpenAI or . public sealed partial class OpenAIChatClient : IChatClient { + private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; + /// Default OpenAI endpoint. private static readonly Uri _defaultOpenAIEndpoint = new("https://api.openai.com/v1"); @@ -123,14 +126,11 @@ public async Task CompleteAsync( { if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) { - Dictionary? arguments = FunctionCallHelpers.ParseFunctionCallArguments(toolCall.FunctionArguments, out Exception? parsingException); + var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + callContent.ModelId = response.Model; + callContent.RawRepresentation = toolCall; - returnMessage.Contents.Add(new FunctionCallContent(toolCall.Id, toolCall.FunctionName, arguments) - { - ModelId = response.Model, - Exception = parsingException, - RawRepresentation = toolCall - }); + returnMessage.Contents.Add(callContent); } } } @@ -320,15 +320,14 @@ public async IAsyncEnumerable CompleteStreamingAs FunctionCallInfo fci = entry.Value; if (!string.IsNullOrWhiteSpace(fci.Name)) { - var arguments = FunctionCallHelpers.ParseFunctionCallArguments( + var callContent = ParseCallContentFromJsonString( fci.Arguments?.ToString() ?? string.Empty, - out Exception? parsingException); + fci.CallId!, + fci.Name!); - completionUpdate.Contents.Add(new FunctionCallContent(fci.CallId!, fci.Name!, arguments) - { - ModelId = modelId, - Exception = parsingException - }); + callContent.ModelId = modelId; + + completionUpdate.Contents.Add(callContent); } } @@ -387,7 +386,7 @@ private static ChatRole ToChatRole(ChatMessageRole role) => }; /// Converts an extensions options instance to an OpenAI options instance. - private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) + private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { ChatCompletionOptions result = new(); @@ -485,7 +484,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) } /// Converts an Extensions function to an OpenAI chat tool. - private ChatTool ToOpenAIChatTool(AIFunction aiFunction) + private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) { bool? strict = aiFunction.Metadata.AdditionalProperties.TryGetValue("Strict", out object? strictObj) && @@ -501,9 +500,7 @@ strictObj is bool strictValue ? foreach (AIFunctionParameterMetadata parameter in parameters) { - tool.Properties.Add( - parameter.Name, - FunctionCallHelpers.InferParameterJsonSchema(parameter, aiFunction.Metadata, ToolCallJsonSerializerOptions)); + tool.Properties.Add(parameter.Name, parameter.Schema is JsonElement e ? e : _defaultParameterSchema); if (parameter.IsRequired) { @@ -598,9 +595,10 @@ private sealed class OpenAIChatToolJson string? result = resultContent.Result as string; if (result is null && resultContent.Result is not null) { + JsonSerializerOptions options = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; try { - result = FunctionCallHelpers.FormatFunctionResultAsJson(resultContent.Result, ToolCallJsonSerializerOptions); + result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -655,7 +653,19 @@ private sealed class OpenAIChatToolJson } } + private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(json, callId, name, + argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject), + exceptionFilter: static ex => ex is JsonException); + + private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, + argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject), + exceptionFilter: static ex => ex is JsonException); + /// Source-generated JSON type information. [JsonSerializable(typeof(OpenAIChatToolJson))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(JsonElement))] private sealed partial class JsonContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index a320600bee2..8b76682f8c8 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -5,12 +5,9 @@ using System.ComponentModel; using System.Reflection; using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Schema; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -using static Microsoft.Extensions.AI.FunctionCallHelpers; namespace Microsoft.Extensions.AI; @@ -19,6 +16,13 @@ namespace Microsoft.Extensions.AI; /// public static class ChatClientStructuredOutputExtensions { + private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new() + { + IncludeSchemaKeyword = true, + DisallowAdditionalProperties = true, + IncludeTypeInEnumSchemas = true + }; + /// Sends chat messages to the model, requesting a response matching the type . /// The . /// The chat content to send. @@ -42,7 +46,7 @@ public static Task> CompleteAsync( bool? useNativeJsonSchema = null, CancellationToken cancellationToken = default) where T : class => - CompleteAsync(chatClient, chatMessages, JsonDefaults.Options, options, useNativeJsonSchema, cancellationToken); + CompleteAsync(chatClient, chatMessages, AIJsonUtilities.DefaultOptions, options, useNativeJsonSchema, cancellationToken); /// Sends a user chat text message to the model, requesting a response matching the type . /// The . @@ -120,26 +124,12 @@ public static async Task> CompleteAsync( serializerOptions.MakeReadOnly(); - var schemaNode = (JsonObject)serializerOptions.GetJsonSchemaAsNode(typeof(T), new() - { - TreatNullObliviousAsNonNullable = true, - TransformSchemaNode = static (context, node) => - { - if (node is JsonObject obj) - { - if (obj.TryGetPropertyValue("enum", out _) - && !obj.TryGetPropertyValue("type", out _)) - { - obj.Insert(0, "type", "string"); - } - } + var schemaNode = AIJsonUtilities.CreateJsonSchema( + type: typeof(T), + serializerOptions: serializerOptions, + inferenceOptions: _inferenceOptions); - return node; - }, - }); - schemaNode.Insert(0, "$schema", "https://json-schema.org/draft/2020-12/schema"); - schemaNode.Add("additionalProperties", false); - var schema = JsonSerializer.Serialize(schemaNode, JsonDefaults.Options.GetTypeInfo(typeof(JsonNode))); + var schema = JsonSerializer.Serialize(schemaNode, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement))); ChatMessage? promptAugmentation = null; options = (options ?? new()).Clone(); @@ -153,7 +143,7 @@ public static async Task> CompleteAsync( // the LLM backend is meant to do whatever's needed to explain the schema to the LLM. options.ResponseFormat = ChatResponseFormat.ForJsonSchema( schema, - schemaName: SanitizeMetadataName(typeof(T).Name), + schemaName: AIFunctionFactory.SanitizeMemberName(typeof(T).Name), schemaDescription: typeof(T).GetCustomAttribute()?.Description); } else diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs index 8c247d73fb3..6ea79f9f738 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs @@ -29,7 +29,7 @@ public DistributedCachingChatClient(IChatClient innerClient, IDistributedCache s : base(innerClient) { _storage = Throw.IfNull(storage); - _jsonSerializerOptions = JsonDefaults.Options; + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; } /// Gets or sets JSON serialization options to use when serializing cache data. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs index 1c268aa08a9..fc01b8c21b9 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs @@ -32,7 +32,7 @@ public LoggingChatClient(IChatClient innerClient, ILogger logger) : base(innerClient) { _logger = Throw.IfNull(logger); - _jsonSerializerOptions = JsonDefaults.Options; + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; } /// Gets or sets JSON serialization options to use when serializing logging data. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index 5129ec9d160..a544e746ae2 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -65,7 +65,7 @@ public OpenTelemetryChatClient(IChatClient innerClient, string? sourceName = nul OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries }); - _jsonSerializerOptions = JsonDefaults.Options; + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; } /// Gets or sets JSON serialization options to use when formatting chat data into telemetry strings. diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs index eda857462a2..a2cf2315b8a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs @@ -34,7 +34,7 @@ public DistributedCachingEmbeddingGenerator(IEmbeddingGeneratorGets or sets JSON serialization options to use when serializing cache data. diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs index cef4c203020..87757849b2e 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGenerator.cs @@ -35,7 +35,7 @@ public LoggingEmbeddingGenerator(IEmbeddingGenerator innerGe : base(innerGenerator) { _logger = Throw.IfNull(logger); - _jsonSerializerOptions = JsonDefaults.Options; + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; } /// Gets or sets JSON serialization options to use when serializing logging data. diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.Utilities.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.Utilities.cs new file mode 100644 index 00000000000..251059035db --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.Utilities.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.Text.RegularExpressions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +public static partial class AIFunctionFactory +{ + /// + /// Removes characters from a .NET member name that shouldn't be used in an AI function name. + /// + /// The .NET member name that should be sanitized. + /// + /// Replaces non-alphanumeric characters in the identifier with the underscore character. + /// Primarily intended to remove characters produced by compiler-generated method name mangling. + /// + internal static string SanitizeMemberName(string memberName) + { + _ = Throw.IfNull(memberName); + return InvalidNameCharsRegex().Replace(memberName, "_"); + } + + /// Regex that flags any character other than ASCII digits or letters or the underscore. +#if NET + [GeneratedRegex("[^0-9A-Za-z_]")] + private static partial Regex InvalidNameCharsRegex(); +#else + private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; + private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); +#endif +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index e48098b378f..b4b022b4a39 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -15,12 +15,11 @@ using System.Threading.Tasks; using Microsoft.Shared.Collections; using Microsoft.Shared.Diagnostics; -using static Microsoft.Extensions.AI.FunctionCallHelpers; namespace Microsoft.Extensions.AI; /// Provides factory methods for creating commonly-used implementations of . -public static class AIFunctionFactory +public static partial class AIFunctionFactory { /// Holds the default options instance used when creating function. private static readonly AIFunctionFactoryCreateOptions _defaultOptions = new(); @@ -40,7 +39,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions? /// The method to be represented via the created . /// The name to use for the . /// The description to use for the . - /// The used to marshal function parameters. + /// The used to marshal function parameters and any return value. /// The created for invoking . public static AIFunction Create(Delegate method, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null) { @@ -86,7 +85,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// /// The name to use for the . /// The description to use for the . - /// The used to marshal function parameters. + /// The used to marshal function parameters and return value. /// The created for invoking . public static AIFunction Create(MethodInfo method, object? target, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null) { @@ -147,7 +146,7 @@ public ReflectionAIFunction(MethodInfo method, object? target, AIFunctionFactory string? functionName = options.Name; if (functionName is null) { - functionName = SanitizeMetadataName(method.Name!); + functionName = SanitizeMemberName(method.Name!); const string AsyncSuffix = "Async"; if (IsAsyncMethod(method) && @@ -210,7 +209,7 @@ static bool IsAsyncMethod(MethodInfo method) { ParameterType = returnType, Description = method.ReturnParameter.GetCustomAttribute(inherit: true)?.Description, - Schema = FunctionCallHelpers.InferReturnParameterJsonSchema(returnType, options.SerializerOptions), + Schema = AIJsonUtilities.CreateJsonSchema(returnType, serializerOptions: options.SerializerOptions), }, AdditionalProperties = options.AdditionalProperties ?? EmptyReadOnlyDictionary.Instance, JsonSerializerOptions = options.SerializerOptions, @@ -356,7 +355,7 @@ static bool IsAsyncMethod(MethodInfo method) DefaultValue = parameter.HasDefaultValue ? parameter.DefaultValue : null, IsRequired = !parameter.IsOptional, ParameterType = parameter.ParameterType, - Schema = FunctionCallHelpers.InferParameterJsonSchema( + Schema = AIJsonUtilities.CreateParameterJsonSchema( parameter.ParameterType, parameter.Name, description, diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs index 8b1ce34bc33..7dbfc6821e8 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.AI; /// public sealed class AIFunctionFactoryCreateOptions { - private JsonSerializerOptions _options = JsonDefaults.Options; + private JsonSerializerOptions _options = AIJsonUtilities.DefaultOptions; /// /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index bda7af37a5a..f360e7d6c43 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -23,9 +23,9 @@ false - - - + + true + diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonSchemaCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonSchemaCreateOptions.cs new file mode 100644 index 00000000000..afa2f236c69 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonSchemaCreateOptions.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +/// +/// An options class for configuring the behavior of JSON schema creation functionality. +/// +public sealed class AIJsonSchemaCreateOptions +{ + /// + /// Gets the default options instance. + /// + public static AIJsonSchemaCreateOptions Default { get; } = new AIJsonSchemaCreateOptions(); + + /// + /// Gets a value indicating whether to include the type keyword in inferred schemas for .NET enums. + /// + public bool IncludeTypeInEnumSchemas { get; init; } + + /// + /// Gets a value indicating whether to generate schemas with the additionalProperties set to false for .NET objects. + /// + public bool DisallowAdditionalProperties { get; init; } + + /// + /// Gets a value indicating whether to include the $schema keyword in inferred schemas. + /// + public bool IncludeSchemaKeyword { get; init; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs similarity index 90% rename from src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs rename to src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs index 7da71aa7fa0..94340160cb1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/JsonDefaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs @@ -11,11 +11,10 @@ namespace Microsoft.Extensions.AI; -/// Provides cached options around JSON serialization to be used by the project. -internal static partial class JsonDefaults +public static partial class AIJsonUtilities { - /// Gets the singleton to use for serialization-related operations. - public static JsonSerializerOptions Options { get; } = CreateDefaultOptions(); + /// Gets the singleton used as the default in JSON serialization operations. + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); /// Creates the default to use for serialization-related operations. [UnconditionalSuppressMessage("AotAnalysis", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] @@ -29,7 +28,7 @@ private static JsonSerializerOptions CreateDefaultOptions() if (JsonSerializer.IsReflectionEnabledByDefault) { // Keep in sync with the JsonSourceGenerationOptions on JsonContext below. - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver(), Converters = { new JsonStringEnumConverter() }, diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs new file mode 100644 index 00000000000..932267fe7cf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs @@ -0,0 +1,348 @@ +// 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.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S1121 // Assignments should not be made from within sub-expressions +#pragma warning disable S107 // Methods should not have too many parameters +#pragma warning disable S1075 // URIs should not be hardcoded + +using FunctionParameterKey = ( + System.Type? Type, + string? ParameterName, + string? Description, + bool HasDefaultValue, + object? DefaultValue, + bool IncludeSchemaUri, + bool DisallowAdditionalProperties, + bool IncludeTypeInEnumSchemas); + +namespace Microsoft.Extensions.AI; + +/// Provides a collection of utility methods for marshalling JSON data. +public static partial class AIJsonUtilities +{ + /// The uri used when populating the $schema keyword in inferred schemas. + private const string SchemaKeywordUri = "https://json-schema.org/draft/2020-12/schema"; + + /// Soft limit for how many items should be stored in the dictionaries in . + private const int CacheSoftLimit = 4096; + + /// Caches of generated schemas for each that's employed. + private static readonly ConditionalWeakTable> _schemaCaches = new(); + + /// Gets a JSON schema accepting all values. + private static readonly JsonElement _trueJsonSchema = ParseJsonElement("true"u8); + + /// Gets a JSON schema only accepting null values. + private static readonly JsonElement _nullJsonSchema = ParseJsonElement("""{"type":"null"}"""u8); + + /// + /// Determines a JSON schema for the provided parameter metadata. + /// + /// The parameter metadata from which to infer the schema. + /// The containing function metadata. + /// The options used to extract the schema from the specified type. + /// The options controlling schema inference. + /// A JSON schema document encoded as a . + public static JsonElement ResolveParameterSchema( + AIFunctionParameterMetadata parameterMetadata, + AIFunctionMetadata functionMetadata, + JsonSerializerOptions? serializerOptions = null, + AIJsonSchemaCreateOptions? inferenceOptions = null) + { + _ = Throw.IfNull(parameterMetadata); + _ = Throw.IfNull(functionMetadata); + + serializerOptions ??= functionMetadata.JsonSerializerOptions ?? DefaultOptions; + + if (ReferenceEquals(serializerOptions, functionMetadata.JsonSerializerOptions) && + parameterMetadata.Schema is JsonElement schema) + { + // If the resolved options matches that of the function metadata, + // we can just return the precomputed JSON schema value. + return schema; + } + + return CreateParameterJsonSchema( + parameterMetadata.ParameterType, + parameterMetadata.Name, + description: parameterMetadata.Description, + hasDefaultValue: parameterMetadata.HasDefaultValue, + defaultValue: parameterMetadata.DefaultValue, + serializerOptions, + inferenceOptions); + } + + /// + /// Creates a JSON schema for the provided parameter metadata. + /// + /// The type of the parameter. + /// The name of the parameter. + /// The description of the parameter. + /// Whether the parameter is optional. + /// The default value of the optional parameter, if applicable. + /// The options used to extract the schema from the specified type. + /// The options controlling schema inference. + /// A JSON schema document encoded as a . + public static JsonElement CreateParameterJsonSchema( + Type? type, + string parameterName, + string? description = null, + bool hasDefaultValue = false, + object? defaultValue = null, + JsonSerializerOptions? serializerOptions = null, + AIJsonSchemaCreateOptions? inferenceOptions = null) + { + _ = Throw.IfNull(parameterName); + + serializerOptions ??= DefaultOptions; + inferenceOptions ??= AIJsonSchemaCreateOptions.Default; + + FunctionParameterKey key = ( + type, + parameterName, + description, + hasDefaultValue, + defaultValue, + IncludeSchemaUri: false, + inferenceOptions.DisallowAdditionalProperties, + inferenceOptions.IncludeTypeInEnumSchemas); + + return GetJsonSchemaCached(serializerOptions, key); + } + + /// Creates a JSON schema for the specified type. + /// The type for which to generate the schema. + /// The description of the parameter. + /// Whether the parameter is optional. + /// The default value of the optional parameter, if applicable. + /// The options used to extract the schema from the specified type. + /// The options controlling schema inference. + /// A representing the schema. + public static JsonElement CreateJsonSchema( + Type? type, + string? description = null, + bool hasDefaultValue = false, + object? defaultValue = null, + JsonSerializerOptions? serializerOptions = null, + AIJsonSchemaCreateOptions? inferenceOptions = null) + { + _ = Throw.IfNull(serializerOptions); + + serializerOptions ??= DefaultOptions; + inferenceOptions ??= AIJsonSchemaCreateOptions.Default; + + FunctionParameterKey key = ( + type, + ParameterName: null, + description, + hasDefaultValue, + defaultValue, + inferenceOptions.IncludeSchemaKeyword, + inferenceOptions.DisallowAdditionalProperties, + inferenceOptions.IncludeTypeInEnumSchemas); + + return GetJsonSchemaCached(serializerOptions, key); + } + + private static JsonElement GetJsonSchemaCached(JsonSerializerOptions options, FunctionParameterKey key) + { + options.MakeReadOnly(); + ConcurrentDictionary cache = _schemaCaches.GetOrCreateValue(options); + + if (cache.Count >= CacheSoftLimit) + { + return GetJsonSchemaCore(options, key); + } + + return cache.GetOrAdd( + key: key, +#if NET + valueFactory: static (key, options) => GetJsonSchemaCore(options, key), + factoryArgument: options); +#else + valueFactory: key => GetJsonSchemaCore(options, key)); +#endif + } + + private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, FunctionParameterKey key) + { + _ = Throw.IfNull(options); + options.MakeReadOnly(); + + if (key.Type is null) + { + // For parameters without a type generate a rudimentary schema with available metadata. + + JsonObject? schemaObj = null; + + if (key.IncludeSchemaUri) + { + (schemaObj = [])["$schema"] = SchemaKeywordUri; + } + + if (key.Description is not null) + { + (schemaObj ??= [])["description"] = key.Description; + } + + if (key.HasDefaultValue) + { + JsonNode? defaultValueNode = key.DefaultValue is { } defaultValue + ? JsonSerializer.Serialize(defaultValue, options.GetTypeInfo(defaultValue.GetType())) + : null; + + (schemaObj ??= [])["default"] = defaultValueNode; + } + + return schemaObj is null + ? _trueJsonSchema + : JsonSerializer.SerializeToElement(schemaObj, JsonContext.Default.JsonNode); + } + + if (key.Type == typeof(void)) + { + return _nullJsonSchema; + } + + JsonSchemaExporterOptions exporterOptions = new() + { + TreatNullObliviousAsNonNullable = true, + TransformSchemaNode = TransformSchemaNode, + }; + + JsonNode node = options.GetJsonSchemaAsNode(key.Type, exporterOptions); + return JsonSerializer.SerializeToElement(node, JsonContext.Default.JsonNode); + + JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) + { + const string SchemaPropertyName = "$schema"; + const string DescriptionPropertyName = "description"; + const string NotPropertyName = "not"; + const string TypePropertyName = "type"; + const string EnumPropertyName = "enum"; + const string PropertiesPropertyName = "properties"; + const string AdditionalPropertiesPropertyName = "additionalProperties"; + const string DefaultPropertyName = "default"; + const string RefPropertyName = "$ref"; + + // Find the first DescriptionAttribute, starting first from the property, then the parameter, and finally the type itself. + Type descAttrType = typeof(DescriptionAttribute); + var descriptionAttribute = + GetAttrs(descAttrType, ctx.PropertyInfo?.AttributeProvider)?.FirstOrDefault() ?? + GetAttrs(descAttrType, ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider)?.FirstOrDefault() ?? + GetAttrs(descAttrType, ctx.TypeInfo.Type)?.FirstOrDefault(); + + if (descriptionAttribute is DescriptionAttribute attr) + { + ConvertSchemaToObject(ref schema).Insert(0, DescriptionPropertyName, (JsonNode)attr.Description); + } + + if (schema is JsonObject objSchema) + { + // 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 (key.ParameterName 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}/{key.ParameterName}" + : $"#/{PropertiesPropertyName}/{key.ParameterName}/{refUri.AsMemory("#/".Length)}"; + + objSchema[RefPropertyName] = (JsonNode)refUri; + } + + // Include the type keyword in enum types + if (key.IncludeTypeInEnumSchemas && ctx.TypeInfo.Type.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) + { + objSchema.Insert(0, TypePropertyName, "string"); + } + + // Disallow additional properties in object schemas + if (key.DisallowAdditionalProperties && objSchema.ContainsKey(PropertiesPropertyName) && !objSchema.ContainsKey(AdditionalPropertiesPropertyName)) + { + objSchema.Add(AdditionalPropertiesPropertyName, (JsonNode)false); + } + } + + if (ctx.Path.IsEmpty) + { + // We are at the root-level schema node, append parameter-specific metadata + + if (!string.IsNullOrWhiteSpace(key.Description)) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + JsonNode descriptionNode = (JsonNode)key.Description!; + int index = obj.IndexOf(DescriptionPropertyName); + if (index < 0) + { + // If there's no description property, insert it at the beginning of the doc. + obj.Insert(0, DescriptionPropertyName, (JsonNode)key.Description!); + } + else + { + // If there is a description property, just update it in-place. + obj[index] = (JsonNode)key.Description!; + } + } + + if (key.HasDefaultValue) + { + JsonNode? defaultValue = JsonSerializer.Serialize(key.DefaultValue, options.GetTypeInfo(typeof(object))); + ConvertSchemaToObject(ref schema)[DefaultPropertyName] = defaultValue; + } + + if (key.IncludeSchemaUri) + { + // The $schema property must be the first keyword in the object + ConvertSchemaToObject(ref schema).Insert(0, SchemaPropertyName, (JsonNode)SchemaKeywordUri); + } + } + + return schema; + + static object[]? GetAttrs(Type attrType, ICustomAttributeProvider? provider) => + provider?.GetCustomAttributes(attrType, inherit: false); + + static JsonObject ConvertSchemaToObject(ref JsonNode schema) + { + JsonObject obj; + JsonValueKind kind = schema.GetValueKind(); + switch (kind) + { + case JsonValueKind.Object: + return (JsonObject)schema; + + case JsonValueKind.False: + schema = obj = new() { [NotPropertyName] = true }; + return obj; + + default: + Debug.Assert(kind is JsonValueKind.True, $"Invalid schema type: {kind}"); + schema = obj = []; + return obj; + } + } + } + } + + private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) + { + Utf8JsonReader reader = new(utf8Json); + return JsonElement.ParseValue(ref reader); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs new file mode 100644 index 00000000000..c4ce6a86014 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public static class AIJsonUtilitiesTests +{ + [Fact] + public static void DefaultOptions_HasExpectedConfiguration() + { + var options = AIJsonUtilities.DefaultOptions; + + // Must be read-only singleton. + Assert.NotNull(options); + Assert.Same(options, AIJsonUtilities.DefaultOptions); + Assert.True(options.IsReadOnly); + + // Must conform to JsonSerializerDefaults.Web + Assert.Equal(JsonNamingPolicy.CamelCase, options.PropertyNamingPolicy); + Assert.True(options.PropertyNameCaseInsensitive); + Assert.Equal(JsonNumberHandling.AllowReadingFromString, options.NumberHandling); + + // Additional settings + Assert.Equal(JsonIgnoreCondition.WhenWritingNull, options.DefaultIgnoreCondition); + Assert.True(options.WriteIndented); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public static void AIJsonSchemaCreateOptions_DefaultInstance_ReturnsExpectedValues(bool useSingleton) + { + AIJsonSchemaCreateOptions options = useSingleton ? AIJsonSchemaCreateOptions.Default : new AIJsonSchemaCreateOptions(); + Assert.False(options.IncludeTypeInEnumSchemas); + Assert.False(options.DisallowAdditionalProperties); + Assert.False(options.IncludeSchemaKeyword); + } + + [Fact] + public static void CreateJsonSchema_DefaultParameters_GeneratesExpectedJsonSchema() + { + JsonElement expected = JsonDocument.Parse(""" + { + "description": "The type", + "type": "object", + "properties": { + "Key": { + "description": "The parameter", + "type": "integer" + }, + "EnumValue": { + "enum": ["A", "B"] + }, + "Value": { + "type": ["string", "null"], + "default": null + } + }, + "required": ["Key", "EnumValue"] + } + """).RootElement; + + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco), serializerOptions: JsonSerializerOptions.Default); + Assert.True(JsonElement.DeepEquals(expected, actual)); + } + + [Fact] + public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSchema() + { + JsonElement expected = JsonDocument.Parse(""" + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "alternative description", + "type": "object", + "properties": { + "Key": { + "description": "The parameter", + "type": "integer" + }, + "EnumValue": { + "type": "string", + "enum": ["A", "B"] + }, + "Value": { + "type": ["string", "null"], + "default": null + } + }, + "required": ["Key", "EnumValue"], + "additionalProperties": false, + "default": "42" + } + """).RootElement; + + AIJsonSchemaCreateOptions inferenceOptions = new AIJsonSchemaCreateOptions + { + IncludeTypeInEnumSchemas = true, + DisallowAdditionalProperties = true, + IncludeSchemaKeyword = true + }; + + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco), + description: "alternative description", + hasDefaultValue: true, + defaultValue: 42, + JsonSerializerOptions.Default, + inferenceOptions); + + Assert.True(JsonElement.DeepEquals(expected, actual)); + } + + [Fact] + public static void ResolveJsonSchema_ReturnsExpectedValue() + { + JsonSerializerOptions options = new(JsonSerializerOptions.Default); + AIFunction func = AIFunctionFactory.Create((int x, int y) => x + y, serializerOptions: options); + + AIFunctionMetadata metadata = func.Metadata; + AIFunctionParameterMetadata param = metadata.Parameters[0]; + JsonElement generatedSchema = Assert.IsType(param.Schema); + + JsonElement resolvedSchema; + resolvedSchema = AIJsonUtilities.ResolveParameterSchema(param, metadata, options); + Assert.True(JsonElement.DeepEquals(generatedSchema, resolvedSchema)); + + options = new(options) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + resolvedSchema = AIJsonUtilities.ResolveParameterSchema(param, metadata, options); + Assert.False(JsonElement.DeepEquals(generatedSchema, resolvedSchema)); + } + + [Description("The type")] + public record MyPoco([Description("The parameter")] int Key, MyEnumValue EnumValue, string? Value = null); + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum MyEnumValue + { + A = 1, + B = 2 + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs index ad513574055..5cfc0711f13 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs @@ -274,4 +274,80 @@ private sealed class NetTypelessAIFunction : AIFunction protected override Task InvokeCoreAsync(IEnumerable>? arguments, CancellationToken cancellationToken) => Task.FromResult(arguments); } + + [Fact] + public static void CreateFromParsedArguments_ObjectJsonInput_ReturnsElementArgumentDictionary() + { + var content = FunctionCallContent.CreateFromParsedArguments( + """{"Key1":{}, "Key2":null, "Key3" : [], "Key4" : 42, "Key5" : true }""", + "callId", + "functionName", + argumentParser: static json => JsonSerializer.Deserialize>(json)); + + Assert.NotNull(content); + Assert.Null(content.Exception); + Assert.NotNull(content.Arguments); + Assert.Equal(5, content.Arguments.Count); + Assert.Collection(content.Arguments, + kvp => + { + Assert.Equal("Key1", kvp.Key); + Assert.True(kvp.Value is JsonElement { ValueKind: JsonValueKind.Object }); + }, + kvp => + { + Assert.Equal("Key2", kvp.Key); + Assert.Null(kvp.Value); + }, + kvp => + { + Assert.Equal("Key3", kvp.Key); + Assert.True(kvp.Value is JsonElement { ValueKind: JsonValueKind.Array }); + }, + kvp => + { + Assert.Equal("Key4", kvp.Key); + Assert.True(kvp.Value is JsonElement { ValueKind: JsonValueKind.Number }); + }, + kvp => + { + Assert.Equal("Key5", kvp.Key); + Assert.True(kvp.Value is JsonElement { ValueKind: JsonValueKind.True }); + }); + } + + [Fact] + public static void CreateFromParsedArguments_ParseException_HasExpectedHandling() + { + FunctionCallContent content; + JsonException exc = new(); + + content = FunctionCallContent.CreateFromParsedArguments(exc, "callId", "functionName", ThrowingParser); + Assert.Null(content.Arguments); + Assert.IsType(content.Exception); + Assert.Same(exc, content.Exception.InnerException); + + content = FunctionCallContent.CreateFromParsedArguments(exc, "callId", "functionName", ThrowingParser, exceptionFilter: IsJsonException); + Assert.Null(content.Arguments); + Assert.IsType(content.Exception); + Assert.Same(exc, content.Exception.InnerException); + + NotSupportedException otherExc = new(); + NotSupportedException thrownEx = Assert.Throws(() => + FunctionCallContent.CreateFromParsedArguments(otherExc, "callId", "functionName", ThrowingParser, exceptionFilter: IsJsonException)); + + Assert.Same(otherExc, thrownEx); + + static Dictionary ThrowingParser(Exception ex) => throw ex; + static bool IsJsonException(Exception ex) => ex is JsonException; + } + + [Fact] + public static void CreateFromParsedArguments_NullInput_ThrowsArgumentNullException() + { + Assert.Throws(() => FunctionCallContent.CreateFromParsedArguments((string)null!, "callId", "functionName", _ => null)); + Assert.Throws(() => FunctionCallContent.CreateFromParsedArguments("{}", null!, "functionName", _ => null)); + Assert.Throws(() => FunctionCallContent.CreateFromParsedArguments("{}", "callId", null!, _ => null)); + Assert.Throws(() => FunctionCallContent.CreateFromParsedArguments("{}", "callId", "functionName", null!)); + } } From 8eddb547b019c5ea46855946aceb1cbca5a7d91e Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 17 Oct 2024 20:51:06 +0100 Subject: [PATCH 040/190] Address PR feedback from https://github.com/dotnet/extensions/pull/5513 (#5533) --- .../Contents/FunctionCallContent.cs | 8 +++--- .../AzureAIInferenceChatClient.cs | 3 +-- .../OpenAIChatClient.cs | 6 ++--- .../Utilities/AIJsonUtilities.Schema.cs | 2 +- .../AIJsonUtilitiesTests.cs | 6 ++--- .../Contents/FunctionCallContentTests..cs | 26 +++++++------------ 6 files changed, 20 insertions(+), 31 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index f106d9b615c..ea3458fb5b6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -64,14 +64,12 @@ public FunctionCallContent(string callId, string name, IDictionaryThe function call ID. /// The function name. /// The parsing implementation converting the encoding to a dictionary of arguments. - /// Filters potential parsing exceptions that should be caught and included in the result. /// A new instance of containing the parse result. public static FunctionCallContent CreateFromParsedArguments( TEncoding encodedArguments, string callId, string name, - Func?> argumentParser, - Func? exceptionFilter = null) + Func?> argumentParser) { _ = Throw.IfNull(callId); _ = Throw.IfNull(name); @@ -81,14 +79,16 @@ public static FunctionCallContent CreateFromParsedArguments( IDictionary? arguments = null; Exception? parsingException = null; +#pragma warning disable CA1031 // Do not catch general exception types try { arguments = argumentParser(encodedArguments); } - catch (Exception ex) when (exceptionFilter is null || exceptionFilter(ex)) + catch (Exception ex) { parsingException = new InvalidOperationException("Error parsing function call arguments.", ex); } +#pragma warning restore CA1031 // Do not catch general exception types return new FunctionCallContent(callId, name, arguments) { diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 93c467618c3..c422e622065 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -492,8 +492,7 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject), - exceptionFilter: static ex => ex is JsonException); + argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)); /// Source-generated JSON type information. [JsonSerializable(typeof(AzureAIChatToolJson))] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index b00e4b52d7f..647c5aaf6ca 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -655,13 +655,11 @@ private sealed class OpenAIChatToolJson private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject), - exceptionFilter: static ex => ex is JsonException); + argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)); private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject), - exceptionFilter: static ex => ex is JsonException); + argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)); /// Source-generated JSON type information. [JsonSerializable(typeof(OpenAIChatToolJson))] diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs index 932267fe7cf..4ad0603d311 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs @@ -55,7 +55,7 @@ public static partial class AIJsonUtilities /// The options used to extract the schema from the specified type. /// The options controlling schema inference. /// A JSON schema document encoded as a . - public static JsonElement ResolveParameterSchema( + public static JsonElement ResolveParameterJsonSchema( AIFunctionParameterMetadata parameterMetadata, AIFunctionMetadata functionMetadata, JsonSerializerOptions? serializerOptions = null, diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs index c4ce6a86014..266f7ec45e9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs @@ -115,7 +115,7 @@ public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSc } [Fact] - public static void ResolveJsonSchema_ReturnsExpectedValue() + public static void ResolveParameterJsonSchema_ReturnsExpectedValue() { JsonSerializerOptions options = new(JsonSerializerOptions.Default); AIFunction func = AIFunctionFactory.Create((int x, int y) => x + y, serializerOptions: options); @@ -125,11 +125,11 @@ public static void ResolveJsonSchema_ReturnsExpectedValue() JsonElement generatedSchema = Assert.IsType(param.Schema); JsonElement resolvedSchema; - resolvedSchema = AIJsonUtilities.ResolveParameterSchema(param, metadata, options); + resolvedSchema = AIJsonUtilities.ResolveParameterJsonSchema(param, metadata, options); Assert.True(JsonElement.DeepEquals(generatedSchema, resolvedSchema)); options = new(options) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; - resolvedSchema = AIJsonUtilities.ResolveParameterSchema(param, metadata, options); + resolvedSchema = AIJsonUtilities.ResolveParameterJsonSchema(param, metadata, options); Assert.False(JsonElement.DeepEquals(generatedSchema, resolvedSchema)); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs index 5cfc0711f13..50ca205197d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs @@ -316,30 +316,22 @@ public static void CreateFromParsedArguments_ObjectJsonInput_ReturnsElementArgum }); } - [Fact] - public static void CreateFromParsedArguments_ParseException_HasExpectedHandling() + [Theory] + [InlineData(typeof(JsonException))] + [InlineData(typeof(InvalidOperationException))] + [InlineData(typeof(NotSupportedException))] + public static void CreateFromParsedArguments_ParseException_HasExpectedHandling(Type exceptionType) { - FunctionCallContent content; - JsonException exc = new(); + Exception exc = (Exception)Activator.CreateInstance(exceptionType)!; + FunctionCallContent content = FunctionCallContent.CreateFromParsedArguments(exc, "callId", "functionName", ThrowingParser); - content = FunctionCallContent.CreateFromParsedArguments(exc, "callId", "functionName", ThrowingParser); + Assert.Equal("functionName", content.Name); + Assert.Equal("callId", content.CallId); Assert.Null(content.Arguments); Assert.IsType(content.Exception); Assert.Same(exc, content.Exception.InnerException); - content = FunctionCallContent.CreateFromParsedArguments(exc, "callId", "functionName", ThrowingParser, exceptionFilter: IsJsonException); - Assert.Null(content.Arguments); - Assert.IsType(content.Exception); - Assert.Same(exc, content.Exception.InnerException); - - NotSupportedException otherExc = new(); - NotSupportedException thrownEx = Assert.Throws(() => - FunctionCallContent.CreateFromParsedArguments(otherExc, "callId", "functionName", ThrowingParser, exceptionFilter: IsJsonException)); - - Assert.Same(otherExc, thrownEx); - static Dictionary ThrowingParser(Exception ex) => throw ex; - static bool IsJsonException(Exception ex) => ex is JsonException; } [Fact] From 8690e7a0ea810ac84b4678e770131d5e7e42b6a7 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 17 Oct 2024 16:55:17 -0400 Subject: [PATCH 041/190] Update UseOpenTelemetry for latest genai spec updates (#5532) * Update UseOpenTelemetry for latest genai spec updates - Events are now expected to be emitted as body fields, and the newly-recommended way to achieve that is via ILogger. So UseOpenTelemetry now takes an optional logger that it uses for emitting such data. - I restructured the implementation to reduce duplication. - Added logging of response format and seed. - Added ChatOptions.TopK, as it's one of the parameters considered special by the spec. - Updated the Azure.AI.Inference provider name to match the convention and what the library itself uses - Updated the OpenAI client to use openai regardless of the kind of the actual client being used, per spec and recommendation * Address PR feedback --- .../ChatCompletion/ChatOptions.cs | 3 + .../AzureAIInferenceChatClient.cs | 11 +- .../OllamaChatClient.cs | 6 +- .../OpenAIChatClient.cs | 6 +- .../OpenAIEmbeddingGenerator.cs | 8 +- .../ChatCompletion/OpenTelemetryChatClient.cs | 494 +++++++++++------- ...penTelemetryChatClientBuilderExtensions.cs | 15 +- .../OpenTelemetryEmbeddingGenerator.cs | 97 ++-- ...etryEmbeddingGeneratorBuilderExtensions.cs | 17 +- .../Microsoft.Extensions.AI.csproj | 1 + .../OpenTelemetryConsts.cs | 42 +- .../AzureAIInferenceChatClientTests.cs | 2 +- .../ChatClientIntegrationTests.cs | 16 +- .../EmbeddingGeneratorIntegrationTests.cs | 2 +- .../OpenAIChatClientTests.cs | 29 +- .../OpenAIEmbeddingGeneratorTests.cs | 29 +- .../OpenTelemetryChatClientTests.cs | 221 ++++---- .../Microsoft.Extensions.AI.Tests.csproj | 1 + 18 files changed, 536 insertions(+), 464 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index 4f02815580e..b3b60c62bad 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -18,6 +18,9 @@ public class ChatOptions /// Gets or sets the "nucleus sampling" factor (or "top p") for generating chat responses. public float? TopP { get; set; } + /// Gets or sets a count indicating how many of the most probable tokens the model should consider when generating the next part of the text. + public int? TopK { get; set; } + /// Gets or sets the frequency penalty for generating chat responses. public float? FrequencyPenalty { get; set; } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index c422e622065..c3313c0c85b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -48,7 +48,7 @@ public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, s var providerUrl = typeof(ChatCompletionsClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(chatCompletionsClient) as Uri; - Metadata = new("AzureAIInference", providerUrl, modelId); + Metadata = new("az.ai.inference", providerUrl, modelId); } /// Gets or sets to use for any serialization activities related to tool call arguments and results. @@ -296,13 +296,19 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, } } + // These properties are strongly-typed on ChatOptions but not on ChatCompletionsOptions. + if (options.TopK is int topK) + { + result.AdditionalProperties["top_k"] = BinaryData.FromObjectAsJson(topK, JsonContext.Default.Options); + } + if (options.AdditionalProperties is { } props) { foreach (var prop in props) { switch (prop.Key) { - // These properties are strongly-typed on the ChatCompletionsOptions class. + // These properties are strongly-typed on the ChatCompletionsOptions class but not on the ChatOptions class. case nameof(result.Seed) when prop.Value is long seed: result.Seed = seed; break; @@ -498,5 +504,6 @@ private static FunctionCallContent ParseCallContentFromJsonString(string json, s [JsonSerializable(typeof(AzureAIChatToolJson))] [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(int))] private sealed partial class JsonContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index dac6f915d83..22ff6db6dab 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -259,7 +259,6 @@ private OllamaChatRequest ToOllamaChatRequest(IList chatMessages, C TransferMetadataValue(nameof(OllamaRequestOptions.repeat_penalty), (options, value) => options.repeat_penalty = value); TransferMetadataValue(nameof(OllamaRequestOptions.seed), (options, value) => options.seed = value); TransferMetadataValue(nameof(OllamaRequestOptions.tfs_z), (options, value) => options.tfs_z = value); - TransferMetadataValue(nameof(OllamaRequestOptions.top_k), (options, value) => options.top_k = 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); @@ -294,6 +293,11 @@ private OllamaChatRequest ToOllamaChatRequest(IList chatMessages, C { (request.Options ??= new()).top_p = topP; } + + if (options.TopK is int topK) + { + (request.Options ??= new()).top_k = topK; + } } return request; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 647c5aaf6ca..dbe415ad818 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -50,11 +50,10 @@ public OpenAIChatClient(OpenAIClient openAIClient, string modelId) // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages // 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. - string providerName = openAIClient.GetType().Name.StartsWith("Azure", StringComparison.Ordinal) ? "azureopenai" : "openai"; Uri providerUrl = typeof(OpenAIClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(openAIClient) as Uri ?? _defaultOpenAIEndpoint; - Metadata = new(providerName, providerUrl, modelId); + Metadata = new("openai", providerUrl, modelId); } /// Initializes a new instance of the class for the specified . @@ -69,13 +68,12 @@ public OpenAIChatClient(ChatClient chatClient) // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages // 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. - string providerName = chatClient.GetType().Name.StartsWith("Azure", StringComparison.Ordinal) ? "azureopenai" : "openai"; Uri providerUrl = typeof(ChatClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(chatClient) as Uri ?? _defaultOpenAIEndpoint; string? model = typeof(ChatClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(chatClient) as string; - Metadata = new(providerName, providerUrl, model); + Metadata = new("openai", providerUrl, model); } /// Gets or sets to use for any serialization activities related to tool call arguments and results. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index 084e235df47..27bf001b3ff 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -52,12 +52,11 @@ public OpenAIEmbeddingGenerator( // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages // 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. - string providerName = openAIClient.GetType().Name.StartsWith("Azure", StringComparison.Ordinal) ? "azureopenai" : "openai"; string providerUrl = (typeof(OpenAIClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(openAIClient) as Uri)?.ToString() ?? DefaultOpenAIEndpoint; - Metadata = CreateMetadata(dimensions, providerName, providerUrl, modelId); + Metadata = CreateMetadata("openai", providerUrl, modelId, dimensions); } /// Initializes a new instance of the class. @@ -78,7 +77,6 @@ public OpenAIEmbeddingGenerator(EmbeddingClient embeddingClient, int? dimensions // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages // 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. - string providerName = embeddingClient.GetType().Name.StartsWith("Azure", StringComparison.Ordinal) ? "azureopenai" : "openai"; string providerUrl = (typeof(EmbeddingClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(embeddingClient) as Uri)?.ToString() ?? DefaultOpenAIEndpoint; @@ -86,11 +84,11 @@ public OpenAIEmbeddingGenerator(EmbeddingClient embeddingClient, int? dimensions FieldInfo? modelField = typeof(EmbeddingClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); string? model = modelField?.GetValue(embeddingClient) as string; - Metadata = CreateMetadata(dimensions, providerName, providerUrl, model); + Metadata = CreateMetadata("openai", providerUrl, model, dimensions); } /// Creates the for this instance. - private static EmbeddingGeneratorMetadata CreateMetadata(int? dimensions, string providerName, string providerUrl, string? model) => + private static EmbeddingGeneratorMetadata CreateMetadata(string providerName, string providerUrl, string? model, int? dimensions) => new(providerName, Uri.TryCreate(providerUrl, UriKind.Absolute, out Uri? providerUri) ? providerUri : null, model, dimensions); /// diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index a544e746ae2..46e26bea181 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -4,13 +4,17 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; using System.Linq; using System.Runtime.CompilerServices; -using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -20,34 +24,40 @@ namespace Microsoft.Extensions.AI; /// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/. /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. /// -public sealed class OpenTelemetryChatClient : DelegatingChatClient +public sealed partial class OpenTelemetryChatClient : DelegatingChatClient { + private const LogLevel EventLogLevel = LogLevel.Information; + private readonly ActivitySource _activitySource; private readonly Meter _meter; + private readonly ILogger _logger; private readonly Histogram _tokenUsageHistogram; private readonly Histogram _operationDurationHistogram; private readonly string? _modelId; - private readonly string? _modelProvider; - private readonly string? _endpointAddress; - private readonly int _endpointPort; + private readonly string? _system; + private readonly string? _serverAddress; + private readonly int _serverPort; private JsonSerializerOptions _jsonSerializerOptions; /// Initializes a new instance of the class. /// The underlying . + /// The to use for emitting events. /// An optional source name that will be used on the telemetry data. - public OpenTelemetryChatClient(IChatClient innerClient, string? sourceName = null) + public OpenTelemetryChatClient(IChatClient innerClient, ILogger? logger = null, string? sourceName = null) : base(innerClient) { Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); + _logger = logger ?? NullLogger.Instance; + ChatClientMetadata metadata = innerClient!.Metadata; _modelId = metadata.ModelId; - _modelProvider = metadata.ProviderName; - _endpointAddress = metadata.ProviderUri?.GetLeftPart(UriPartial.Path); - _endpointPort = metadata.ProviderUri?.Port ?? 0; + _system = metadata.ProviderName; + _serverAddress = metadata.ProviderUri?.GetLeftPart(UriPartial.Path); + _serverPort = metadata.ProviderUri?.Port ?? 0; string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; _activitySource = new(name); @@ -88,27 +98,32 @@ protected override void Dispose(bool disposing) } /// - /// Gets or sets a value indicating whether potentially sensitive information (e.g. prompts) should be included in telemetry. + /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. /// /// - /// The value is by default, meaning that telemetry will include metadata such as token counts but not the raw text of prompts or completions. + /// The value is by default, meaning that telemetry will include metadata such as token counts but not raw inputs + /// and outputs such as message content, function call arguments, and function call results. /// public bool EnableSensitiveData { get; set; } /// public override async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) { + _ = Throw.IfNull(chatMessages); _jsonSerializerOptions.MakeReadOnly(); - using Activity? activity = StartActivity(chatMessages, options); + using Activity? activity = CreateAndConfigureActivity(options); Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; string? requestModelId = options?.ModelId ?? _modelId; - ChatCompletion? response = null; + LogChatMessages(chatMessages); + + ChatCompletion? completion = null; Exception? error = null; try { - response = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + completion = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + return completion; } catch (Exception ex) { @@ -117,35 +132,37 @@ public override async Task CompleteAsync(IList chat } finally { - SetCompletionResponse(activity, requestModelId, response, error, stopwatch); + TraceCompletion(activity, requestModelId, completion, error, stopwatch); } - - return response; } /// public override async IAsyncEnumerable CompleteStreamingAsync( IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + _ = Throw.IfNull(chatMessages); _jsonSerializerOptions.MakeReadOnly(); - using Activity? activity = StartActivity(chatMessages, options); + using Activity? activity = CreateAndConfigureActivity(options); Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; string? requestModelId = options?.ModelId ?? _modelId; - IAsyncEnumerable response; + LogChatMessages(chatMessages); + + IAsyncEnumerable updates; try { - response = base.CompleteStreamingAsync(chatMessages, options, cancellationToken); + updates = base.CompleteStreamingAsync(chatMessages, options, cancellationToken); } catch (Exception ex) { - SetCompletionResponse(activity, requestModelId, null, ex, stopwatch); + TraceCompletion(activity, requestModelId, completion: null, ex, stopwatch); throw; } - var responseEnumerator = response.ConfigureAwait(false).GetAsyncEnumerator(); - List? streamedContents = activity is not null ? [] : null; + var responseEnumerator = updates.ConfigureAwait(false).GetAsyncEnumerator(); + List trackedUpdates = []; + Exception? error = null; try { while (true) @@ -162,167 +179,154 @@ public override async IAsyncEnumerable CompleteSt } catch (Exception ex) { - SetCompletionResponse(activity, requestModelId, null, ex, stopwatch); + error = ex; throw; } - streamedContents?.Add(update); + trackedUpdates.Add(update); yield return update; } } finally { - if (activity is not null) - { - UsageContent? usageContent = streamedContents?.SelectMany(c => c.Contents).OfType().LastOrDefault(); - SetCompletionResponse( - activity, - stopwatch, - requestModelId, - OrganizeStreamingContent(streamedContents), - streamedContents?.SelectMany(c => c.Contents).OfType(), - usage: usageContent?.Details); - } + TraceCompletion(activity, requestModelId, ComposeStreamingUpdatesIntoChatCompletion(trackedUpdates), error, stopwatch); await responseEnumerator.DisposeAsync(); } } - /// Gets a value indicating whether diagnostics are enabled. - private bool Enabled => _activitySource.HasListeners(); - - /// Convert chat history to a string aligned with the OpenAI format. - private static string ToOpenAIFormat(IEnumerable messages, JsonSerializerOptions serializerOptions) + /// Creates a from a collection of instances. + /// + /// This only propagates information that's later used by the telemetry. If additional information from the + /// is needed, this implementation should be updated to include it. + /// + private static ChatCompletion ComposeStreamingUpdatesIntoChatCompletion( + List updates) { - var sb = new StringBuilder().Append('['); - - string messageSeparator = string.Empty; - foreach (var message in messages) + // Group updates by choice index. + Dictionary> choices = []; + foreach (var update in updates) { - _ = sb.Append(messageSeparator); - messageSeparator = ", \n"; - - string text = string.Concat(message.Contents.OfType().Select(c => c.Text)); - _ = sb.Append("{\"role\": \"").Append(message.Role).Append("\", \"content\": ").Append(JsonSerializer.Serialize(text, serializerOptions.GetTypeInfo(typeof(string)))); - - if (message.Contents.OfType().Any()) + if (!choices.TryGetValue(update.ChoiceIndex, out var choiceContents)) { - _ = sb.Append(", \"tool_calls\": ").Append('['); - - string messageItemSeparator = string.Empty; - foreach (var functionCall in message.Contents.OfType()) - { - _ = sb.Append(messageItemSeparator); - messageItemSeparator = ", \n"; - - _ = sb.Append("{\"id\": \"").Append(functionCall.CallId) - .Append("\", \"function\": {\"arguments\": ").Append(JsonSerializer.Serialize(functionCall.Arguments, serializerOptions.GetTypeInfo(typeof(IDictionary)))) - .Append(", \"name\": \"").Append(functionCall.Name) - .Append("\"}, \"type\": \"function\"}"); - } - - _ = sb.Append(']'); + choices[update.ChoiceIndex] = choiceContents = []; } - _ = sb.Append('}'); - } - - _ = sb.Append(']'); - return sb.ToString(); - } - - /// Organize streaming content by choice index. - private static Dictionary> OrganizeStreamingContent(IEnumerable? contents) - { - Dictionary> choices = []; - if (contents is null) - { - return choices; + choiceContents.Add(update); } - foreach (var content in contents) + // Add a ChatMessage for each choice. + string? id = null; + ChatFinishReason? finishReason = null; + string? modelId = null; + List messages = new(choices.Count); + foreach (var choice in choices.OrderBy(c => c.Key)) { - if (!choices.TryGetValue(content.ChoiceIndex, out var choiceContents)) + ChatRole? role = null; + List items = []; + foreach (var update in choice.Value) { - choices[content.ChoiceIndex] = choiceContents = []; + id ??= update.CompletionId; + finishReason ??= update.FinishReason; + role ??= update.Role; + items.AddRange(update.Contents); + modelId ??= update.Contents.FirstOrDefault(c => c.ModelId is not null)?.ModelId; } - choiceContents.Add(content); + messages.Add(new ChatMessage(role ?? ChatRole.Assistant, items)); } - return choices; + return new(messages) + { + CompletionId = id, + FinishReason = finishReason, + ModelId = modelId, + Usage = updates.SelectMany(c => c.Contents).OfType().LastOrDefault()?.Details, + }; } /// Creates an activity for a chat completion request, or returns null if not enabled. - private Activity? StartActivity(IList chatMessages, ChatOptions? options) + private Activity? CreateAndConfigureActivity(ChatOptions? options) { Activity? activity = null; - if (Enabled) + if (_activitySource.HasListeners()) { string? modelId = options?.ModelId ?? _modelId; activity = _activitySource.StartActivity( - $"chat.completions {modelId}", - ActivityKind.Client, - default(ActivityContext), - [ - new(OpenTelemetryConsts.GenAI.Operation.Name, "chat"), - new(OpenTelemetryConsts.GenAI.Request.Model, modelId), - new(OpenTelemetryConsts.GenAI.System, _modelProvider), - ]); + $"{OpenTelemetryConsts.GenAI.Chat} {modelId}", + ActivityKind.Client); if (activity is not null) { - if (_endpointAddress is not null) + _ = activity + .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Chat) + .AddTag(OpenTelemetryConsts.GenAI.Request.Model, modelId) + .AddTag(OpenTelemetryConsts.GenAI.SystemName, _system); + + if (_serverAddress is not null) { _ = activity - .SetTag(OpenTelemetryConsts.Server.Address, _endpointAddress) - .SetTag(OpenTelemetryConsts.Server.Port, _endpointPort); + .AddTag(OpenTelemetryConsts.Server.Address, _serverAddress) + .AddTag(OpenTelemetryConsts.Server.Port, _serverPort); } if (options is not null) { if (options.FrequencyPenalty is float frequencyPenalty) { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.FrequencyPenalty, frequencyPenalty); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.FrequencyPenalty, frequencyPenalty); } if (options.MaxOutputTokens is int maxTokens) { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.MaxTokens, maxTokens); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.MaxTokens, maxTokens); } if (options.PresencePenalty is float presencePenalty) { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.PresencePenalty, presencePenalty); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.PresencePenalty, presencePenalty); } if (options.StopSequences is IList stopSequences) { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.StopSequences, $"[{string.Join(", ", stopSequences.Select(s => $"\"{s}\""))}]"); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.StopSequences, $"[{string.Join(", ", stopSequences.Select(s => $"\"{s}\""))}]"); } if (options.Temperature is float temperature) { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.Temperature, temperature); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.Temperature, temperature); } - if (options.AdditionalProperties?.TryGetValue("top_k", out double topK) is true) + if (options.TopK is int topK) { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.TopK, topK); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.TopK, topK); } if (options.TopP is float top_p) { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.TopP, top_p); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.TopP, top_p); } - } - if (EnableSensitiveData) - { - _ = activity.AddEvent(new ActivityEvent( - OpenTelemetryConsts.GenAI.Content.Prompt, - tags: new ActivityTagsCollection([new(OpenTelemetryConsts.GenAI.Prompt, ToOpenAIFormat(chatMessages, _jsonSerializerOptions))]))); + if (_system is not null) + { + if (options.ResponseFormat is not null) + { + string responseFormat = options.ResponseFormat switch + { + ChatResponseFormatText => "text", + ChatResponseFormatJson { Schema: null } => "json_schema", + ChatResponseFormatJson => "json_object", + _ => "_OTHER", + }; + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.PerProvider(_system, "response_format"), responseFormat); + } + + if (options.AdditionalProperties?.TryGetValue("seed", out long seed) is true) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.PerProvider(_system, "seed"), seed); + } + } } } } @@ -331,23 +335,18 @@ private static Dictionary> OrganizeStre } /// Adds chat completion information to the activity. - private void SetCompletionResponse( + private void TraceCompletion( Activity? activity, string? requestModelId, - ChatCompletion? completions, + ChatCompletion? completion, Exception? error, Stopwatch? stopwatch) { - if (!Enabled) - { - return; - } - if (_operationDurationHistogram.Enabled && stopwatch is not null) { TagList tags = default; - AddMetricTags(ref tags, requestModelId, completions); + AddMetricTags(ref tags, requestModelId, completion); if (error is not null) { tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); @@ -356,13 +355,13 @@ private void SetCompletionResponse( _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); } - if (_tokenUsageHistogram.Enabled && completions?.Usage is { } usage) + if (_tokenUsageHistogram.Enabled && completion?.Usage is { } usage) { if (usage.InputTokenCount is int inputTokens) { TagList tags = default; tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "input"); - AddMetricTags(ref tags, requestModelId, completions); + AddMetricTags(ref tags, requestModelId, completion); _tokenUsageHistogram.Record(inputTokens); } @@ -370,139 +369,230 @@ private void SetCompletionResponse( { TagList tags = default; tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "output"); - AddMetricTags(ref tags, requestModelId, completions); + AddMetricTags(ref tags, requestModelId, completion); _tokenUsageHistogram.Record(outputTokens); } } - if (activity is null) - { - return; - } - if (error is not null) { - _ = activity - .SetTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + _ = activity? + .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) .SetStatus(ActivityStatusCode.Error, error.Message); - return; } - if (completions is not null) + if (completion is not null) { - if (completions.FinishReason is ChatFinishReason finishReason) + LogChatCompletion(completion); + + if (activity is not null) { + if (completion.FinishReason is ChatFinishReason finishReason) + { #pragma warning disable CA1308 // Normalize strings to uppercase - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.FinishReasons, $"[\"{finishReason.Value.ToLowerInvariant()}\"]"); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.FinishReasons, $"[\"{finishReason.Value.ToLowerInvariant()}\"]"); #pragma warning restore CA1308 - } + } - if (!string.IsNullOrWhiteSpace(completions.CompletionId)) - { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.Id, completions.CompletionId); - } + if (!string.IsNullOrWhiteSpace(completion.CompletionId)) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Id, completion.CompletionId); + } - if (completions.ModelId is not null) - { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.Model, completions.ModelId); + if (completion.ModelId is not null) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Model, completion.ModelId); + } + + if (completion.Usage?.InputTokenCount is int inputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.InputTokens, inputTokens); + } + + if (completion.Usage?.OutputTokenCount is int outputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.OutputTokens, outputTokens); + } } + } + + void AddMetricTags(ref TagList tags, string? requestModelId, ChatCompletion? completions) + { + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Chat); - if (completions.Usage?.InputTokenCount is int inputTokens) + if (requestModelId is not null) { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.InputTokens, inputTokens); + tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); } - if (completions.Usage?.OutputTokenCount is int outputTokens) + tags.Add(OpenTelemetryConsts.GenAI.SystemName, _system); + + if (_serverAddress is string endpointAddress) { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.OutputTokens, outputTokens); + tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Port, _serverPort); } - if (EnableSensitiveData) + if (completions?.ModelId is string responseModel) { - _ = activity.AddEvent(new ActivityEvent( - OpenTelemetryConsts.GenAI.Content.Completion, - tags: new ActivityTagsCollection([new(OpenTelemetryConsts.GenAI.Completion, ToOpenAIFormat(completions.Choices, _jsonSerializerOptions))]))); + tags.Add(OpenTelemetryConsts.GenAI.Response.Model, responseModel); } } } - /// Adds streaming chat completion information to the activity. - private void SetCompletionResponse( - Activity? activity, - Stopwatch? stopwatch, - string? requestModelId, - Dictionary> choices, - IEnumerable? toolCalls, - UsageDetails? usage) + private void LogChatMessages(IEnumerable messages) { - if (activity is null || !Enabled || choices.Count == 0) + if (!_logger.IsEnabled(EventLogLevel)) { return; } - string? id = null; - ChatFinishReason? finishReason = null; - string? modelId = null; - List messages = new(choices.Count); - - foreach (var choice in choices) + foreach (ChatMessage message in messages) { - ChatRole? role = null; - List items = []; - foreach (var update in choice.Value) + if (message.Role == ChatRole.Assistant) { - id ??= update.CompletionId; - role ??= update.Role; - finishReason ??= update.FinishReason; - foreach (AIContent content in update.Contents) + Log(new(1, OpenTelemetryConsts.GenAI.Assistant.Message), + JsonSerializer.Serialize(CreateAssistantEvent(message), OtelContext.Default.AssistantEvent)); + } + else if (message.Role == ChatRole.Tool) + { + foreach (FunctionResultContent frc in message.Contents.OfType()) { - items.Add(content); - modelId ??= content.ModelId; + Log(new(1, OpenTelemetryConsts.GenAI.Tool.Message), + JsonSerializer.Serialize(new() + { + Id = frc.CallId, + Content = EnableSensitiveData && frc.Result is object result ? + JsonSerializer.SerializeToNode(result, _jsonSerializerOptions.GetTypeInfo(result.GetType())) : + null, + }, OtelContext.Default.ToolEvent)); } } + else + { + Log(new(1, message.Role == ChatRole.System ? OpenTelemetryConsts.GenAI.System.Message : OpenTelemetryConsts.GenAI.User.Message), + JsonSerializer.Serialize(new() + { + Role = message.Role != ChatRole.System && message.Role != ChatRole.User && !string.IsNullOrWhiteSpace(message.Role.Value) ? message.Role.Value : null, + Content = GetMessageContent(message), + }, OtelContext.Default.SystemOrUserEvent)); + } + } + } - messages.Add(new ChatMessage(role ?? ChatRole.Assistant, items)); + private void LogChatCompletion(ChatCompletion completion) + { + if (!_logger.IsEnabled(EventLogLevel)) + { + return; } - if (toolCalls is not null && messages.FirstOrDefault()?.Contents is { } c) + EventId id = new(1, OpenTelemetryConsts.GenAI.Choice); + int choiceCount = completion.Choices.Count; + for (int choiceIndex = 0; choiceIndex < choiceCount; choiceIndex++) { - foreach (var functionCall in toolCalls) + Log(id, JsonSerializer.Serialize(new() { - c.Add(functionCall); - } + FinishReason = completion.FinishReason?.Value ?? "error", + Index = choiceIndex, + Message = CreateAssistantEvent(completion.Choices[choiceIndex]), + }, OtelContext.Default.ChoiceEvent)); } + } - ChatCompletion completion = new(messages) - { - CompletionId = id, - FinishReason = finishReason, - ModelId = modelId, - Usage = usage, - }; + private void Log(EventId id, [StringSyntax(StringSyntaxAttribute.Json)] string eventBodyJson) + { + // This is not the idiomatic way to log, but it's necessary for now in order to structure + // the data in a way that the OpenTelemetry collector can work with it. The event body + // can be very large and should not be logged as an attribute. - SetCompletionResponse(activity, requestModelId, completion, error: null, stopwatch); + KeyValuePair[] tags = + [ + new(OpenTelemetryConsts.Event.Name, id.Name), + new(OpenTelemetryConsts.GenAI.SystemName, _system), + ]; + + _logger.Log(EventLogLevel, id, tags, null, (_, __) => eventBodyJson); } - private void AddMetricTags(ref TagList tags, string? requestModelId, ChatCompletion? completions) + private AssistantEvent CreateAssistantEvent(ChatMessage message) { - tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, "chat"); + var toolCalls = message.Contents.OfType().Select(fc => new ToolCall + { + Id = fc.CallId, + Function = new() + { + Name = fc.Name, + Arguments = EnableSensitiveData ? + JsonSerializer.SerializeToNode(fc.Arguments, _jsonSerializerOptions.GetTypeInfo(typeof(IDictionary))) : + null, + }, + }).ToArray(); + + return new() + { + Content = GetMessageContent(message), + ToolCalls = toolCalls.Length > 0 ? toolCalls : null, + }; + } - if (requestModelId is not null) + private string? GetMessageContent(ChatMessage message) + { + if (EnableSensitiveData) { - tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); + string content = string.Concat(message.Contents.OfType().Select(c => c.Text)); + if (content.Length > 0) + { + return content; + } } - tags.Add(OpenTelemetryConsts.GenAI.System, _modelProvider); + return null; + } - if (_endpointAddress is string endpointAddress) - { - tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); - tags.Add(OpenTelemetryConsts.Server.Port, _endpointPort); - } + private sealed class SystemOrUserEvent + { + public string? Role { get; set; } + public string? Content { get; set; } + } - if (completions?.ModelId is string responseModel) - { - tags.Add(OpenTelemetryConsts.GenAI.Response.Model, responseModel); - } + private sealed class AssistantEvent + { + public string? Content { get; set; } + public ToolCall[]? ToolCalls { get; set; } } + + private sealed class ToolEvent + { + public string? Id { get; set; } + public JsonNode? Content { get; set; } + } + + private sealed class ChoiceEvent + { + public string? FinishReason { get; set; } + public int Index { get; set; } + public AssistantEvent? Message { get; set; } + } + + private sealed class ToolCall + { + public string? Id { get; set; } + public string? Type { get; set; } = "function"; + public ToolCallFunction? Function { get; set; } + } + + private sealed class ToolCallFunction + { + public string? Name { get; set; } + public JsonNode? Arguments { get; set; } + } + + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + [JsonSerializable(typeof(SystemOrUserEvent))] + [JsonSerializable(typeof(AssistantEvent))] + [JsonSerializable(typeof(ToolEvent))] + [JsonSerializable(typeof(ChoiceEvent))] + [JsonSerializable(typeof(object))] + private sealed partial class OtelContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs index bf1ff4e9f0d..6e04e16f507 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -17,15 +19,22 @@ public static class OpenTelemetryChatClientBuilderExtensions /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. /// /// The . + /// An optional to use to create a logger for logging events. /// An optional source name that will be used on the telemetry data. /// An optional callback that can be used to configure the instance. /// The . public static ChatClientBuilder UseOpenTelemetry( - this ChatClientBuilder builder, string? sourceName = null, Action? configure = null) => - Throw.IfNull(builder).Use(innerClient => + this ChatClientBuilder builder, + ILoggerFactory? loggerFactory = null, + string? sourceName = null, + Action? configure = null) => + Throw.IfNull(builder).Use((services, innerClient) => { - var chatClient = new OpenTelemetryChatClient(innerClient, sourceName); + loggerFactory ??= services.GetService(); + + var chatClient = new OpenTelemetryChatClient(innerClient, loggerFactory?.CreateLogger(typeof(OpenTelemetryChatClient)), sourceName); configure?.Invoke(chatClient); + return chatClient; }); } diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs index 8105cc64bdf..c085aaef350 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -38,8 +39,11 @@ public sealed class OpenTelemetryEmbeddingGenerator : Delega /// Initializes a new instance of the class. /// /// The underlying , which is the next stage of the pipeline. + /// The to use for emitting events. /// An optional source name that will be used on the telemetry data. - public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator innerGenerator, string? sourceName = null) +#pragma warning disable IDE0060 // Remove unused parameter; it exists for future use and consistency with OpenTelemetryChatClient + public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator innerGenerator, ILogger? logger = null, string? sourceName = null) +#pragma warning restore IDE0060 : base(innerGenerator) { Debug.Assert(innerGenerator is not null, "Should have been validated by the base ctor."); @@ -68,27 +72,12 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator i advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries }); } - /// - protected override void Dispose(bool disposing) - { - if (disposing) - { - _activitySource.Dispose(); - _meter.Dispose(); - } - - base.Dispose(disposing); - } - - /// Gets a value indicating whether diagnostics are enabled. - private bool Enabled => _activitySource.HasListeners(); - /// public override async Task> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(values); - using Activity? activity = StartActivity(); + using Activity? activity = CreateAndConfigureActivity(); Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; GeneratedEmbeddings? response = null; @@ -104,26 +93,38 @@ public override async Task> GenerateAsync(IEnume } finally { - SetCompletionResponse(activity, response, error, stopwatch); + TraceCompletion(activity, response, error, stopwatch); } return response; } + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activitySource.Dispose(); + _meter.Dispose(); + } + + base.Dispose(disposing); + } + /// Creates an activity for an embedding generation request, or returns null if not enabled. - private Activity? StartActivity() + private Activity? CreateAndConfigureActivity() { Activity? activity = null; - if (Enabled) + if (_activitySource.HasListeners()) { activity = _activitySource.StartActivity( - $"embedding {_modelId}", + $"{OpenTelemetryConsts.GenAI.Embed} {_modelId}", ActivityKind.Client, default(ActivityContext), [ - new(OpenTelemetryConsts.GenAI.Operation.Name, "embedding"), + new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embed), new(OpenTelemetryConsts.GenAI.Request.Model, _modelId), - new(OpenTelemetryConsts.GenAI.System, _modelProvider), + new(OpenTelemetryConsts.GenAI.SystemName, _modelProvider), ]); if (activity is not null) @@ -131,13 +132,13 @@ public override async Task> GenerateAsync(IEnume if (_endpointAddress is not null) { _ = activity - .SetTag(OpenTelemetryConsts.Server.Address, _endpointAddress) - .SetTag(OpenTelemetryConsts.Server.Port, _endpointPort); + .AddTag(OpenTelemetryConsts.Server.Address, _endpointAddress) + .AddTag(OpenTelemetryConsts.Server.Port, _endpointPort); } if (_dimensions is int dimensions) { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Request.EmbeddingDimensions, dimensions); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.EmbeddingDimensions, dimensions); } } } @@ -146,17 +147,12 @@ public override async Task> GenerateAsync(IEnume } /// Adds embedding generation response information to the activity. - private void SetCompletionResponse( + private void TraceCompletion( Activity? activity, GeneratedEmbeddings? embeddings, Exception? error, Stopwatch? stopwatch) { - if (!Enabled) - { - return; - } - int? inputTokens = null; string? responseModelId = null; if (embeddings is not null) @@ -189,40 +185,37 @@ private void SetCompletionResponse( _tokenUsageHistogram.Record(inputTokens.Value); } - if (activity is null) + if (activity is not null) { - return; - } - - if (error is not null) - { - _ = activity - .SetTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) - .SetStatus(ActivityStatusCode.Error, error.Message); - return; - } + if (error is not null) + { + _ = activity + .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + } - if (inputTokens.HasValue) - { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.InputTokens, inputTokens); - } + if (inputTokens.HasValue) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.InputTokens, inputTokens); + } - if (responseModelId is not null) - { - _ = activity.SetTag(OpenTelemetryConsts.GenAI.Response.Model, responseModelId); + if (responseModelId is not null) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Model, responseModelId); + } } } private void AddMetricTags(ref TagList tags, string? responseModelId) { - tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, "embedding"); + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embed); if (_modelId is string requestModel) { tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModel); } - tags.Add(OpenTelemetryConsts.GenAI.System, _modelProvider); + tags.Add(OpenTelemetryConsts.GenAI.SystemName, _modelProvider); if (_endpointAddress is string endpointAddress) { diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs index ba60847ef93..bffb9087abf 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -19,15 +21,24 @@ public static class OpenTelemetryEmbeddingGeneratorBuilderExtensions /// The type of input used to produce embeddings. /// The type of embedding generated. /// The . + /// An optional to use to create a logger for logging events. /// An optional source name that will be used on the telemetry data. /// An optional callback that can be used to configure the instance. /// The . public static EmbeddingGeneratorBuilder UseOpenTelemetry( - this EmbeddingGeneratorBuilder builder, string? sourceName = null, Action>? configure = null) + this EmbeddingGeneratorBuilder builder, + ILoggerFactory? loggerFactory = null, + string? sourceName = null, + Action>? configure = null) where TEmbedding : Embedding => - Throw.IfNull(builder).Use(innerGenerator => + Throw.IfNull(builder).Use((services, innerGenerator) => { - var generator = new OpenTelemetryEmbeddingGenerator(innerGenerator, sourceName); + loggerFactory ??= services.GetService(); + + var generator = new OpenTelemetryEmbeddingGenerator( + innerGenerator, + loggerFactory?.CreateLogger(typeof(OpenTelemetryEmbeddingGenerator)), + sourceName); configure?.Invoke(generator); return generator; }); diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index f360e7d6c43..2d695c88fcb 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -20,6 +20,7 @@ true + true false diff --git a/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs index 31e61101a13..27a543705ba 100644 --- a/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs +++ b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs @@ -3,7 +3,6 @@ namespace Microsoft.Extensions.AI; -#pragma warning disable S3218 // Inner class members should not shadow outer class "static" or type members #pragma warning disable CA1716 // Identifiers should not match keywords #pragma warning disable S4041 // Type names should not match namespaces @@ -15,6 +14,11 @@ internal static class OpenTelemetryConsts public const string SecondsUnit = "s"; public const string TokensUnit = "token"; + public static class Event + { + public const string Name = "event.name"; + } + public static class Error { public const string Type = "error.type"; @@ -22,9 +26,16 @@ public static class Error public static class GenAI { - public const string Completion = "gen_ai.completion"; - public const string Prompt = "gen_ai.prompt"; - public const string System = "gen_ai.system"; + public const string Choice = "gen_ai.choice"; + public const string SystemName = "gen_ai.system"; + + public const string Chat = "chat"; + public const string Embed = "embed"; + + public static class Assistant + { + public const string Message = "gen_ai.assistant.message"; + } public static class Client { @@ -43,12 +54,6 @@ public static class TokenUsage } } - public static class Content - { - public const string Completion = "gen_ai.content.completion"; - public const string Prompt = "gen_ai.content.prompt"; - } - public static class Operation { public const string Name = "gen_ai.operation.name"; @@ -65,6 +70,8 @@ public static class Request public const string Temperature = "gen_ai.request.temperature"; public const string TopK = "gen_ai.request.top_k"; public const string TopP = "gen_ai.request.top_p"; + + public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.request.{parameterName}"; } public static class Response @@ -76,10 +83,25 @@ public static class Response public const string OutputTokens = "gen_ai.response.output_tokens"; } + public static class System + { + public const string Message = "gen_ai.system.message"; + } + public static class Token { public const string Type = "gen_ai.token.type"; } + + public static class Tool + { + public const string Message = "gen_ai.tool.message"; + } + + public static class User + { + public const string Message = "gen_ai.user.message"; + } } public static class Server diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index fd4bd11a96f..be628c13d0d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -47,7 +47,7 @@ public void AsChatClient_ProducesExpectedMetadata() ChatCompletionsClient client = new(endpoint, new AzureKeyCredential("key")); IChatClient chatClient = client.AsChatClient(model); - Assert.Equal("AzureAIInference", chatClient.Metadata.ProviderName); + Assert.Equal("az.ai.inference", chatClient.Metadata.ProviderName); Assert.Equal(endpoint, chatClient.Metadata.ProviderUri); Assert.Equal(model, chatClient.Metadata.ModelId); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 09784e86d16..634e4a19f9e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -545,13 +545,13 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() .Build(); var chatClient = new ChatClientBuilder() - .UseOpenTelemetry(sourceName, instance => { instance.EnableSensitiveData = true; }) + .UseOpenTelemetry(sourceName: sourceName) .Use(CreateChatClient()!); var response = await chatClient.CompleteAsync([new(ChatRole.User, "What's the biggest animal?")]); var activity = Assert.Single(activities); - Assert.StartsWith("chat.completions", activity.DisplayName); + Assert.StartsWith("chat", activity.DisplayName); Assert.StartsWith("http", (string)activity.GetTagItem("server.address")!); Assert.Equal(chatClient.Metadata.ProviderUri?.Port, (int)activity.GetTagItem("server.port")!); Assert.NotNull(activity.Id); @@ -559,18 +559,6 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.response.input_tokens")!); Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.response.output_tokens")!); - Assert.Collection(activity.Events, - evt => - { - Assert.Equal("gen_ai.content.prompt", evt.Name); - Assert.Equal("""[{"role": "user", "content": "What\u0027s the biggest animal?"}]""", evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.prompt").Value); - }, - evt => - { - Assert.Equal("gen_ai.content.completion", evt.Name); - Assert.Contains("whale", (string)evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.completion").Value!); - }); - Assert.True(activity.Duration.TotalMilliseconds > 0); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs index 252427836e8..29502f926c6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs @@ -111,7 +111,7 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() .Build(); var embeddingGenerator = new EmbeddingGeneratorBuilder>() - .UseOpenTelemetry(sourceName) + .UseOpenTelemetry(sourceName: sourceName) .Use(CreateEmbeddingGenerator()!); _ = await embeddingGenerator.GenerateAsync("Hello, world!"); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 947deb2674d..adc245c58e8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -45,13 +45,17 @@ public void AsChatClient_InvalidArgs_Throws() Assert.Throws("modelId", () => client.AsChatClient(" ")); } - [Fact] - public void AsChatClient_OpenAIClient_ProducesExpectedMetadata() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AsChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - OpenAIClient client = new(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = useAzureOpenAI ? + new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : + new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.AsChatClient(model); Assert.Equal("openai", chatClient.Metadata.ProviderName); @@ -64,25 +68,6 @@ public void AsChatClient_OpenAIClient_ProducesExpectedMetadata() Assert.Equal(model, chatClient.Metadata.ModelId); } - [Fact] - public void AsChatClient_AzureOpenAIClient_ProducesExpectedMetadata() - { - Uri endpoint = new("http://localhost/some/endpoint"); - string model = "amazingModel"; - - AzureOpenAIClient client = new(endpoint, new ApiKeyCredential("key")); - - IChatClient chatClient = client.AsChatClient(model); - Assert.Equal("azureopenai", chatClient.Metadata.ProviderName); - Assert.Equal(endpoint, chatClient.Metadata.ProviderUri); - Assert.Equal(model, chatClient.Metadata.ModelId); - - chatClient = client.GetChatClient(model).AsChatClient(); - Assert.Equal("azureopenai", chatClient.Metadata.ProviderName); - Assert.Equal(endpoint, chatClient.Metadata.ProviderUri); - Assert.Equal(model, chatClient.Metadata.ModelId); - } - [Fact] public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index d08cf295a4b..50b64fc9196 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -42,13 +42,17 @@ public void AsEmbeddingGenerator_InvalidArgs_Throws() Assert.Throws("modelId", () => client.AsEmbeddingGenerator(" ")); } - [Fact] - public void AsEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AsEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - OpenAIClient client = new(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = useAzureOpenAI ? + new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : + new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IEmbeddingGenerator> embeddingGenerator = client.AsEmbeddingGenerator(model); Assert.Equal("openai", embeddingGenerator.Metadata.ProviderName); @@ -61,25 +65,6 @@ public void AsEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() Assert.Equal(model, embeddingGenerator.Metadata.ModelId); } - [Fact] - public void AsEmbeddingGenerator_AzureOpenAIClient_ProducesExpectedMetadata() - { - Uri endpoint = new("http://localhost/some/endpoint"); - string model = "amazingModel"; - - AzureOpenAIClient client = new(endpoint, new ApiKeyCredential("key")); - - IEmbeddingGenerator> embeddingGenerator = client.AsEmbeddingGenerator(model); - Assert.Equal("azureopenai", embeddingGenerator.Metadata.ProviderName); - Assert.Equal(endpoint, embeddingGenerator.Metadata.ProviderUri); - Assert.Equal(model, embeddingGenerator.Metadata.ModelId); - - embeddingGenerator = client.GetEmbeddingClient(model).AsEmbeddingGenerator(); - Assert.Equal("azureopenai", embeddingGenerator.Metadata.ProviderName); - Assert.Equal(endpoint, embeddingGenerator.Metadata.ProviderUri); - Assert.Equal(model, embeddingGenerator.Metadata.ModelId); - } - [Fact] public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs index d0056b21b91..2ad428fad76 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs @@ -4,10 +4,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using OpenTelemetry.Trace; using Xunit; @@ -15,8 +16,12 @@ namespace Microsoft.Extensions.AI; public class OpenTelemetryChatClientTests { - [Fact] - public async Task ExpectedInformationLogged_NonStreaming_Async() + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ExpectedInformationLogged_Async(bool enableSensitiveData, bool streaming) { var sourceName = Guid.NewGuid().ToString(); var activities = new List(); @@ -25,13 +30,16 @@ public async Task ExpectedInformationLogged_NonStreaming_Async() .AddInMemoryExporter(activities) .Build(); + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using var innerClient = new TestChatClient { Metadata = new("testservice", new Uri("http://localhost:12345/something"), "amazingmodel"), CompleteAsyncCallback = async (messages, options, cancellationToken) => { await Task.Yield(); - return new ChatCompletion([new ChatMessage(ChatRole.Assistant, "blue whale")]) + return new ChatCompletion([new ChatMessage(ChatRole.Assistant, "The blue whale, I think.")]) { CompletionId = "id123", FinishReason = ChatFinishReason.Stop, @@ -42,99 +50,31 @@ public async Task ExpectedInformationLogged_NonStreaming_Async() TotalTokenCount = 42, }, }; - } - }; - - var chatClient = new ChatClientBuilder() - .UseOpenTelemetry(sourceName, instance => - { - instance.EnableSensitiveData = true; - instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; - }) - .Use(innerClient); - - await chatClient.CompleteAsync( - [new(ChatRole.User, "What's the biggest animal?")], - new ChatOptions - { - FrequencyPenalty = 3.0f, - MaxOutputTokens = 123, - ModelId = "replacementmodel", - TopP = 4.0f, - PresencePenalty = 5.0f, - ResponseFormat = ChatResponseFormat.Json, - Temperature = 6.0f, - StopSequences = ["hello", "world"], - AdditionalProperties = new() { ["top_k"] = 7.0f }, - }); - - var activity = Assert.Single(activities); - - Assert.NotNull(activity.Id); - Assert.NotEmpty(activity.Id); - - Assert.Equal("http://localhost:12345/something", activity.GetTagItem("server.address")); - Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); - - Assert.Equal("chat.completions replacementmodel", activity.DisplayName); - Assert.Equal("testservice", activity.GetTagItem("gen_ai.system")); - - Assert.Equal("replacementmodel", activity.GetTagItem("gen_ai.request.model")); - Assert.Equal(3.0f, activity.GetTagItem("gen_ai.request.frequency_penalty")); - Assert.Equal(4.0f, activity.GetTagItem("gen_ai.request.top_p")); - Assert.Equal(5.0f, activity.GetTagItem("gen_ai.request.presence_penalty")); - Assert.Equal(6.0f, activity.GetTagItem("gen_ai.request.temperature")); - Assert.Equal(7.0, activity.GetTagItem("gen_ai.request.top_k")); - Assert.Equal(123, activity.GetTagItem("gen_ai.request.max_tokens")); - Assert.Equal("""["hello", "world"]""", activity.GetTagItem("gen_ai.request.stop_sequences")); - - Assert.Equal("id123", activity.GetTagItem("gen_ai.response.id")); - Assert.Equal("""["stop"]""", activity.GetTagItem("gen_ai.response.finish_reasons")); - Assert.Equal(10, activity.GetTagItem("gen_ai.response.input_tokens")); - Assert.Equal(20, activity.GetTagItem("gen_ai.response.output_tokens")); - - Assert.Collection(activity.Events, - evt => - { - Assert.Equal("gen_ai.content.prompt", evt.Name); - Assert.Equal("""[{"role": "user", "content": "What\u0027s the biggest animal?"}]""", evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.prompt").Value); }, - evt => - { - Assert.Equal("gen_ai.content.completion", evt.Name); - Assert.Contains("whale", (string)evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.completion").Value!); - }); - - Assert.True(activity.Duration.TotalMilliseconds > 0); - } - - [Fact] - public async Task ExpectedInformationLogged_Streaming_Async() - { - var sourceName = Guid.NewGuid().ToString(); - var activities = new List(); - using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build(); + CompleteStreamingAsyncCallback = CallbackAsync, + }; async static IAsyncEnumerable CallbackAsync( IList messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.Yield(); - yield return new StreamingChatCompletionUpdate + + foreach (string text in new[] { "The ", "blue ", "whale,", " ", "", "I", " think." }) { - Role = ChatRole.Assistant, - Text = "blue ", - CompletionId = "id123", - }; - await Task.Yield(); + await Task.Yield(); + yield return new StreamingChatCompletionUpdate + { + Role = ChatRole.Assistant, + Text = text, + CompletionId = "id123", + }; + } + yield return new StreamingChatCompletionUpdate { - Role = ChatRole.Assistant, - Text = "whale", FinishReason = ChatFinishReason.Stop, }; + yield return new StreamingChatCompletionUpdate { Contents = [new UsageContent(new() @@ -146,36 +86,47 @@ async static IAsyncEnumerable CallbackAsync( }; } - using var innerClient = new TestChatClient - { - Metadata = new("testservice", new Uri("http://localhost:12345/something"), "amazingmodel"), - CompleteStreamingAsyncCallback = CallbackAsync, - }; - var chatClient = new ChatClientBuilder() - .UseOpenTelemetry(sourceName, instance => + .UseOpenTelemetry(loggerFactory, sourceName, configure: instance => { - instance.EnableSensitiveData = true; + instance.EnableSensitiveData = enableSensitiveData; instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; }) .Use(innerClient); - await foreach (var update in chatClient.CompleteStreamingAsync( - [new(ChatRole.User, "What's the biggest animal?")], - new ChatOptions + List chatMessages = + [ + new(ChatRole.System, "You are a close friend."), + new(ChatRole.User, "Hey!"), + new(ChatRole.Assistant, [new FunctionCallContent("12345", "GetPersonName")]), + new(ChatRole.Tool, [new FunctionResultContent("12345", "GetPersonName", "John")]), + new(ChatRole.Assistant, "Hey John, what's up?"), + new(ChatRole.User, "What's the biggest animal?") + ]; + + var options = new ChatOptions + { + FrequencyPenalty = 3.0f, + MaxOutputTokens = 123, + ModelId = "replacementmodel", + TopP = 4.0f, + TopK = 7, + PresencePenalty = 5.0f, + ResponseFormat = ChatResponseFormat.Json, + Temperature = 6.0f, + StopSequences = ["hello", "world"], + }; + + if (streaming) + { + await foreach (var update in chatClient.CompleteStreamingAsync(chatMessages, options)) { - FrequencyPenalty = 3.0f, - MaxOutputTokens = 123, - ModelId = "replacementmodel", - TopP = 4.0f, - PresencePenalty = 5.0f, - ResponseFormat = ChatResponseFormat.Json, - Temperature = 6.0f, - StopSequences = ["hello", "world"], - AdditionalProperties = new() { ["top_k"] = 7.0 }, - })) + await Task.Yield(); + } + } + else { - // Drain the stream. + await chatClient.CompleteAsync(chatMessages, options); } var activity = Assert.Single(activities); @@ -186,7 +137,7 @@ async static IAsyncEnumerable CallbackAsync( Assert.Equal("http://localhost:12345/something", activity.GetTagItem("server.address")); Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); - Assert.Equal("chat.completions replacementmodel", activity.DisplayName); + Assert.Equal("chat replacementmodel", activity.DisplayName); Assert.Equal("testservice", activity.GetTagItem("gen_ai.system")); Assert.Equal("replacementmodel", activity.GetTagItem("gen_ai.request.model")); @@ -194,7 +145,7 @@ async static IAsyncEnumerable CallbackAsync( Assert.Equal(4.0f, activity.GetTagItem("gen_ai.request.top_p")); Assert.Equal(5.0f, activity.GetTagItem("gen_ai.request.presence_penalty")); Assert.Equal(6.0f, activity.GetTagItem("gen_ai.request.temperature")); - Assert.Equal(7.0, activity.GetTagItem("gen_ai.request.top_k")); + Assert.Equal(7, activity.GetTagItem("gen_ai.request.top_k")); Assert.Equal(123, activity.GetTagItem("gen_ai.request.max_tokens")); Assert.Equal("""["hello", "world"]""", activity.GetTagItem("gen_ai.request.stop_sequences")); @@ -203,18 +154,44 @@ async static IAsyncEnumerable CallbackAsync( Assert.Equal(10, activity.GetTagItem("gen_ai.response.input_tokens")); Assert.Equal(20, activity.GetTagItem("gen_ai.response.output_tokens")); - Assert.Collection(activity.Events, - evt => - { - Assert.Equal("gen_ai.content.prompt", evt.Name); - Assert.Equal("""[{"role": "user", "content": "What\u0027s the biggest animal?"}]""", evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.prompt").Value); - }, - evt => - { - Assert.Equal("gen_ai.content.completion", evt.Name); - Assert.Contains("whale", (string)evt.Tags.FirstOrDefault(t => t.Key == "gen_ai.completion").Value!); - }); - Assert.True(activity.Duration.TotalMilliseconds > 0); + + var logs = collector.GetSnapshot(); + if (enableSensitiveData) + { + Assert.Collection(logs, + log => Assert.Equal("""{"content":"You are a close friend."}""", log.Message), + log => Assert.Equal("""{"content":"Hey!"}""", log.Message), + log => Assert.Equal("""{"tool_calls":[{"id":"12345","type":"function","function":{"name":"GetPersonName"}}]}""", log.Message), + log => Assert.Equal("""{"id":"12345","content":"John"}""", log.Message), + log => Assert.Equal("""{"content":"Hey John, what\u0027s up?"}""", log.Message), + log => Assert.Equal("""{"content":"What\u0027s the biggest animal?"}""", log.Message), + log => Assert.Equal("""{"finish_reason":"stop","index":0,"message":{"content":"The blue whale, I think."}}""", log.Message)); + } + else + { + Assert.Collection(logs, + log => Assert.Equal("""{}""", log.Message), + log => Assert.Equal("""{}""", log.Message), + log => Assert.Equal("""{"tool_calls":[{"id":"12345","type":"function","function":{"name":"GetPersonName"}}]}""", log.Message), + log => Assert.Equal("""{"id":"12345"}""", log.Message), + log => Assert.Equal("""{}""", log.Message), + log => Assert.Equal("""{}""", log.Message), + log => Assert.Equal("""{"finish_reason":"stop","index":0,"message":{}}""", log.Message)); + } + + Assert.Collection(logs, + log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.system.message"), ((IList>)log.State!)[0]), + log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.user.message"), ((IList>)log.State!)[0]), + log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.assistant.message"), ((IList>)log.State!)[0]), + log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.tool.message"), ((IList>)log.State!)[0]), + log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.assistant.message"), ((IList>)log.State!)[0]), + log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.user.message"), ((IList>)log.State!)[0]), + log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.choice"), ((IList>)log.State!)[0])); + + Assert.All(logs, log => + { + Assert.Equal(new KeyValuePair("gen_ai.system", "testservice"), ((IList>)log.State!)[1]); + }); } } 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 b3d5e8048f5..8675bdcf2f4 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 @@ -26,6 +26,7 @@ + From eafdf6e9c40bcd561f38979617405fd2801a46e3 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 18 Oct 2024 18:55:05 -0700 Subject: [PATCH 042/190] Use 8.0 era dependencies for non net9.0 TFMs (#5470) * Use 8.0 era dependencies for non net9.0 TFMs * PR Feedback. * Apply suggestions from code review Co-authored-by: Eric Erhardt * Split files per feedback and update dependency versions to latest servicing and RC2 * Removing latest version from Microsoft.Extensions.AI.Abstractions * Removing unnecessary package reference * Also removing property from AzureAIInference project --------- Co-authored-by: Eric Erhardt --- eng/Version.Details.xml | 40 +++++++------- eng/Versions.props | 55 +++++++++++++++++-- eng/packages/General-LTS.props | 41 ++++++++++++++ eng/packages/General-net9.props | 41 ++++++++++++++ eng/packages/General.props | 36 +----------- .../Microsoft.AspNetCore.HeaderParsing.csproj | 6 +- ...icrosoft.Extensions.AI.Abstractions.csproj | 2 +- .../AzureAIInferenceChatClient.cs | 2 +- .../Microsoft.Extensions.AI.Ollama.csproj | 2 + .../Microsoft.Extensions.AI.OpenAI.csproj | 3 - .../OpenAIChatClient.cs | 4 +- .../Microsoft.Extensions.AI.csproj | 2 + ...Microsoft.Extensions.Caching.Hybrid.csproj | 3 + 13 files changed, 171 insertions(+), 66 deletions(-) create mode 100644 eng/packages/General-LTS.props create mode 100644 eng/packages/General-net9.props diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 8dec69a1b07..2264967d976 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,9 +1,5 @@ - - https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 - https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 990ebf52fc408ca45929fd176d2740675a67fab8 @@ -84,6 +80,22 @@ https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 990ebf52fc408ca45929fd176d2740675a67fab8 + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + 990ebf52fc408ca45929fd176d2740675a67fab8 + - 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 @@ -61,9 +61,8 @@ 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 8.0.5 9.0.0-rc.2.24473.5 + 9.0.0-rc.2.24473.5 9.0.0-rc.2.24474.3 9.0.0-rc.2.24474.3 @@ -75,13 +74,59 @@ 9.0.0-rc.2.24474.3 9.0.0-rc.2.24474.3 + + + 8.0.0 + 8.0.1 + 8.0.0 + 8.0.1 + 8.0.0 + 8.0.2 + 8.0.1 + 8.0.0 + 8.0.2 + 8.0.1 + 8.0.1 + 8.0.1 + 8.0.1 + 8.0.1 + 8.0.2 + 8.0.1 + 8.0.1 + 8.0.1 + 8.0.0 + 8.0.2 + 8.0.10 + 8.0.10 + 8.0.0 + 8.0.1 + 8.0.1 + 8.0.1 + 8.0.0 + 8.0.0 + 8.0.1 + 8.0.1 + 8.0.1 + 8.0.1 + 8.0.2 + 8.0.0 + 8.0.5 + + 8.0.10 + 8.0.10 + 8.0.10 + 8.0.10 + 8.0.10 + 8.0.10 + 8.0.10 + 8.0.10 + 8.0.10 + diff --git a/eng/packages/General-LTS.props b/eng/packages/General-LTS.props new file mode 100644 index 00000000000..b82ee443a77 --- /dev/null +++ b/eng/packages/General-LTS.props @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/eng/packages/General-net9.props b/eng/packages/General-net9.props new file mode 100644 index 00000000000..8f7bae8b816 --- /dev/null +++ b/eng/packages/General-net9.props @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/eng/packages/General.props b/eng/packages/General.props index ce9c0579971..fbefcb50550 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -3,36 +3,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -41,21 +17,12 @@ - - - - - - - - - @@ -76,4 +43,7 @@ + + + diff --git a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj index dfff6aaffa1..adbae73bd9e 100644 --- a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj +++ b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj @@ -8,11 +8,15 @@ $(NetCoreTargetFrameworks) - true + + true true true true true + + false + $(NoWarn);IL2026 diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index b7f8b935b57..bb1a3b63708 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -25,7 +25,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index c3313c0c85b..784e0388a1b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -498,7 +498,7 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)); + argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); /// Source-generated JSON type information. [JsonSerializable(typeof(AzureAIChatToolJson))] diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj index 81beb0d7bed..416eeeca6e0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj @@ -4,6 +4,8 @@ Microsoft.Extensions.AI Implementation of generative AI abstractions for Ollama. AI + + true 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 327f0e6f692..87dda461c50 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 @@ -26,9 +26,6 @@ - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index dbe415ad818..50c9b43c58b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -653,11 +653,11 @@ private sealed class OpenAIChatToolJson private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)); + argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)); + argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); /// Source-generated JSON type information. [JsonSerializable(typeof(OpenAIChatToolJson))] diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 2d695c88fcb..2b91bf8d3a6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -4,6 +4,8 @@ Microsoft.Extensions.AI Utilities for working with generative AI components. AI + + true diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj index f460c4ee0cc..1c59ccc088a 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj @@ -17,6 +17,9 @@ 75 50 Fundamentals + + true From bdbda816432a90a3af550c9f8254ff58a0d6dc5f Mon Sep 17 00:00:00 2001 From: Makazeu Date: Mon, 21 Oct 2024 19:52:32 +0800 Subject: [PATCH 043/190] Update the meter names of NetworkMetrics to match other meters in ResourceMonitoring (#5541) * Update the meter names in ResourceMonitoring * Add Unit Tests --- .../Linux/Network/LinuxNetworkMetrics.cs | 2 +- .../Windows/Network/WindowsNetworkMetrics.cs | 2 +- .../Linux/LinuxNetworkMetricsTests.cs | 27 +++++++++++++++++++ .../Windows/WindowsNetworkMetricsTests.cs | 27 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxNetworkMetricsTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsNetworkMetricsTests.cs diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxNetworkMetrics.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxNetworkMetrics.cs index 7e073564efc..44a2512a916 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxNetworkMetrics.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxNetworkMetrics.cs @@ -19,7 +19,7 @@ public LinuxNetworkMetrics(IMeterFactory meterFactory, ITcpStateInfoProvider tcp // We don't dispose the meter because IMeterFactory handles that // Is's a false-positive, see: https://github.com/dotnet/roslyn-analyzers/issues/6912 // Related documentation: https://github.com/dotnet/docs/pull/37170. - var meter = meterFactory.Create(nameof(ResourceMonitoring)); + var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope KeyValuePair tcpTag = new("network.transport", "tcp"); diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsNetworkMetrics.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsNetworkMetrics.cs index 661e02f0eae..d0ce26f7044 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsNetworkMetrics.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsNetworkMetrics.cs @@ -19,7 +19,7 @@ public WindowsNetworkMetrics(IMeterFactory meterFactory, ITcpStateInfoProvider t // We don't dispose the meter because IMeterFactory handles that // Is's a false-positive, see: https://github.com/dotnet/roslyn-analyzers/issues/6912. // Related documentation: https://github.com/dotnet/docs/pull/37170 - var meter = meterFactory.Create(nameof(ResourceMonitoring)); + var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope KeyValuePair tcpTag = new("network.transport", "tcp"); diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxNetworkMetricsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxNetworkMetricsTests.cs new file mode 100644 index 00000000000..a1e38b293d9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxNetworkMetricsTests.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.Metrics; +using System.Linq; +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network; +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; +using Microsoft.TestUtilities; +using Moq; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Test; + +[OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] +public class LinuxNetworkMetricsTests +{ + [Fact] + public void Creates_Meter_With_Correct_Name() + { + using var meterFactory = new TestMeterFactory(); + var tcpStateInfoProviderMock = new Mock(); + _ = new LinuxNetworkMetrics(meterFactory, tcpStateInfoProviderMock.Object); + + Meter meter = meterFactory.Meters.Single(); + Assert.Equal(ResourceUtilizationInstruments.MeterName, meter.Name); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsNetworkMetricsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsNetworkMetricsTests.cs new file mode 100644 index 00000000000..1680eb00479 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsNetworkMetricsTests.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.Metrics; +using System.Linq; +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Network; +using Microsoft.TestUtilities; +using Moq; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Test; + +[OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX, SkipReason = "Windows specific.")] +public class WindowsNetworkMetricsTests +{ + [ConditionalFact] + public void Creates_Meter_With_Correct_Name() + { + using var meterFactory = new TestMeterFactory(); + var tcpStateInfoProviderMock = new Mock(); + _ = new WindowsNetworkMetrics(meterFactory, tcpStateInfoProviderMock.Object); + + Meter meter = meterFactory.Meters.Single(); + Assert.Equal(ResourceUtilizationInstruments.MeterName, meter.Name); + } +} From cd4dc68991b2af2335221253acc3d62107a18ec7 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 21 Oct 2024 10:10:23 -0400 Subject: [PATCH 044/190] Fix typo in ChatCompletions comment (#5545) --- .../ChatCompletion/ChatCompletion.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs index 2a9237d9b5a..729483e7c30 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs @@ -59,7 +59,7 @@ public ChatMessage Message /// Gets or sets the ID of the chat completion. public string? CompletionId { get; set; } - /// Gets or sets the model ID using in the creation of the chat completion. + /// Gets or sets the model ID used in the creation of the chat completion. public string? ModelId { get; set; } /// Gets or sets a timestamp for the chat completion. From 16224139952758ae7d21ac4268fdcb2004a0a54a Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 21 Oct 2024 21:15:53 -0400 Subject: [PATCH 045/190] Remove unnecessary ctors from FunctionResultContent (#5536) --- .../Contents/FunctionResultContent.cs | 34 ------------------- .../FunctionInvokingChatClient.cs | 2 +- .../ChatCompletion/ChatMessageTests.cs | 4 +-- .../StreamingChatCompletionUpdateTests.cs | 2 +- .../Contents/FunctionResultContentTests.cs | 24 +++---------- 5 files changed, 8 insertions(+), 58 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs index f793e2ceceb..731716e5427 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs @@ -33,40 +33,6 @@ public FunctionResultContent(string callId, string name, object? result) Result = result; } - /// - /// Initializes a new instance of the class. - /// - /// The function call ID for which this is the result. - /// The function name that produced the result. - /// - /// This may be if the function returned , if the function was void-returning - /// and thus had no result, or if the function call failed. Typically, however, in order to provide meaningfully representative - /// information to an AI service, a human-readable representation of those conditions should be supplied. - /// - /// Any exception that occurred when invoking the function. - public FunctionResultContent(string callId, string name, object? result, Exception? exception) - { - CallId = Throw.IfNull(callId); - Name = Throw.IfNull(name); - Result = result; - Exception = exception; - } - - /// - /// Initializes a new instance of the class. - /// - /// The function call for which this is the result. - /// - /// This may be if the function returned , if the function was void-returning - /// and thus had no result, or if the function call failed. Typically, however, in order to provide meaningfully representative - /// information to an AI service, a human-readable representation of those conditions should be supplied. - /// - /// Any exception that occurred when invoking the function. - public FunctionResultContent(FunctionCallContent functionCall, object? result, Exception? exception = null) - : this(Throw.IfNull(functionCall).CallId, functionCall.Name, result, exception) - { - } - /// /// Gets or sets the ID of the function call for which this is the result. /// diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 94b87c9a7b1..308480635d8 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -552,7 +552,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul functionResult = message; } - return new FunctionResultContent(result.CallContent.CallId, result.CallContent.Name, functionResult, result.Exception); + return new FunctionResultContent(result.CallContent.CallId, result.CallContent.Name, functionResult) { Exception = result.Exception }; } } 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 dbef5f4088b..e05e0d0ef47 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs @@ -128,7 +128,7 @@ public void Text_GetSet_UsesFirstTextContent() new FunctionCallContent("callId1", "fc1"), new TextContent("text-1"), new TextContent("text-2"), - new FunctionResultContent(new FunctionCallContent("callId1", "fc2"), "result"), + new FunctionResultContent("callId1", "fc2", "result"), ]); TextContent textContent = Assert.IsType(message.Contents[3]); @@ -291,7 +291,7 @@ public void ItCanBeSerializeAndDeserialized() AdditionalProperties = new() { ["metadata-key-6"] = "metadata-value-6" } }, new FunctionCallContent("function-id", "plugin-name-function-name", new Dictionary { ["parameter"] = "argument" }), - new FunctionResultContent(new FunctionCallContent("function-id", "plugin-name-function-name"), "function-result"), + new FunctionResultContent("function-id", "plugin-name-function-name", "function-result"), ]; // Act diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs index 988727b1159..f90f799c6f9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs @@ -96,7 +96,7 @@ public void Text_GetSet_UsesFirstTextContent() new FunctionCallContent("callId1", "fc1"), new TextContent("text-1"), new TextContent("text-2"), - new FunctionResultContent(new FunctionCallContent("callId1", "fc2"), "result"), + new FunctionResultContent("callId1", "fc2", "result"), ], }; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs index a70386e42c6..10a23c69596 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs @@ -25,30 +25,14 @@ public void Constructor_PropsDefault() [Fact] public void Constructor_String_PropsRoundtrip() { - Exception e = new(); - - FunctionResultContent c = new("id", "name", "result", e); + FunctionResultContent c = new("id", "name", "result"); Assert.Null(c.RawRepresentation); Assert.Null(c.ModelId); Assert.Null(c.AdditionalProperties); Assert.Equal("name", c.Name); Assert.Equal("id", c.CallId); Assert.Equal("result", c.Result); - Assert.Same(e, c.Exception); - } - - [Fact] - public void Constructor_FunctionCallContent_PropsRoundtrip() - { - Exception e = new(); - - FunctionResultContent c = new(new FunctionCallContent("id", "name"), "result", e); - Assert.Null(c.RawRepresentation); - Assert.Null(c.ModelId); - Assert.Null(c.AdditionalProperties); - Assert.Equal("id", c.CallId); - Assert.Equal("result", c.Result); - Assert.Same(e, c.Exception); + Assert.Null(c.Exception); } [Fact] @@ -88,7 +72,7 @@ public void Constructor_PropsRoundtrip() public void ItShouldBeSerializableAndDeserializable() { // Arrange - var sut = new FunctionResultContent(new FunctionCallContent("id", "p1-f1"), "result"); + var sut = new FunctionResultContent("id", "p1-f1", "result"); // Act var json = JsonSerializer.Serialize(sut, TestJsonSerializerContext.Default.Options); @@ -106,7 +90,7 @@ public void ItShouldBeSerializableAndDeserializable() public void ItShouldBeSerializableAndDeserializableWithException() { // Arrange - var sut = new FunctionResultContent("callId1", "functionName", null, new InvalidOperationException("hello")); + var sut = new FunctionResultContent("callId1", "functionName", null) { Exception = new InvalidOperationException("hello") }; // Act var json = JsonSerializer.Serialize(sut, TestJsonSerializerContext.Default.Options); From e1eb9bdbe3d22080ee4936e0b6c79474ced84e3e Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 21 Oct 2024 21:16:53 -0400 Subject: [PATCH 046/190] Make GenerateAsync extension just return the embedding (#5543) --- .../EmbeddingGeneratorExtensions.cs | 11 ++++++-- .../EmbeddingGeneratorExtensionsTests.cs | 2 +- .../EmbeddingGeneratorIntegrationTests.cs | 2 +- ...istributedCachingEmbeddingGeneratorTest.cs | 27 +++++++------------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs index fa2a1df4fbe..944ce0995f8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.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; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -18,7 +19,7 @@ public static class EmbeddingGeneratorExtensions /// The embedding generation options to configure the request. /// The to monitor for cancellation requests. The default is . /// The generated embedding for the specified . - public static Task> GenerateAsync( + public static async Task GenerateAsync( this IEmbeddingGenerator generator, TValue value, EmbeddingGenerationOptions? options = null, @@ -28,6 +29,12 @@ public static Task> GenerateAsync>>([result]) }; - Assert.Same(result, (await service.GenerateAsync("hello"))[0]); + Assert.Same(result, await service.GenerateAsync("hello")); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs index 29502f926c6..1929869c487 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs @@ -44,7 +44,7 @@ public virtual async Task GenerateEmbedding_CreatesEmbeddingSuccessfully() { SkipIfNotEnabled(); - var embeddings = await _embeddingGenerator.GenerateAsync("Using AI with .NET"); + var embeddings = await _embeddingGenerator.GenerateAsync(["Using AI with .NET"]); Assert.NotNull(embeddings.Usage); Assert.NotNull(embeddings.Usage.InputTokenCount); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs index 2b4370222c6..9a5086a146d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs @@ -44,17 +44,15 @@ public async Task CachesSuccessResultsAsync() // Make the initial request and do a quick sanity check var result1 = await outer.GenerateAsync("abc"); - Assert.Single(result1); - AssertEmbeddingsEqual(_expectedEmbedding, result1[0]); + AssertEmbeddingsEqual(_expectedEmbedding, result1); Assert.Equal(1, innerCallCount); // Act var result2 = await outer.GenerateAsync("abc"); // Assert - Assert.Single(result2); Assert.Equal(1, innerCallCount); - AssertEmbeddingsEqual(_expectedEmbedding, result2[0]); + AssertEmbeddingsEqual(_expectedEmbedding, result2); // Act/Assert 2: Cache misses do not return cached results await outer.GenerateAsync(["def"]); @@ -144,13 +142,13 @@ public async Task AllowsConcurrentCallsAsync() Assert.False(result1.IsCompleted); Assert.False(result2.IsCompleted); completionTcs.SetResult(true); - AssertEmbeddingsEqual(_expectedEmbedding, (await result1)[0]); - AssertEmbeddingsEqual(_expectedEmbedding, (await result2)[0]); + AssertEmbeddingsEqual(_expectedEmbedding, await result1); + AssertEmbeddingsEqual(_expectedEmbedding, await result2); // Act 2: Subsequent calls after completion are resolved from the cache var result3 = await outer.GenerateAsync("abc"); Assert.Equal(2, innerCallCount); - AssertEmbeddingsEqual(_expectedEmbedding, (await result1)[0]); + AssertEmbeddingsEqual(_expectedEmbedding, await result1); } [Fact] @@ -218,9 +216,8 @@ public async Task DoesNotCacheCanceledResultsAsync() // Act/Assert: Second call can succeed var result2 = await outer.GenerateAsync("abc"); - Assert.Single(result2); Assert.Equal(2, innerCallCount); - AssertEmbeddingsEqual(_expectedEmbedding, result2[0]); + AssertEmbeddingsEqual(_expectedEmbedding, result2); } [Fact] @@ -254,11 +251,9 @@ public async Task CacheKeyDoesNotVaryByEmbeddingOptionsAsync() }); // Assert: Same result - Assert.Single(result1); - Assert.Single(result2); Assert.Equal(1, innerCallCount); - AssertEmbeddingsEqual(_expectedEmbedding, result1[0]); - AssertEmbeddingsEqual(_expectedEmbedding, result2[0]); + AssertEmbeddingsEqual(_expectedEmbedding, result1); + AssertEmbeddingsEqual(_expectedEmbedding, result2); } [Fact] @@ -292,11 +287,9 @@ public async Task SubclassCanOverrideCacheKeyToVaryByOptionsAsync() }); // Assert: Different results - Assert.Single(result1); - Assert.Single(result2); Assert.Equal(2, innerCallCount); - AssertEmbeddingsEqual(_expectedEmbedding, result1[0]); - AssertEmbeddingsEqual(_expectedEmbedding, result2[0]); + AssertEmbeddingsEqual(_expectedEmbedding, result1); + AssertEmbeddingsEqual(_expectedEmbedding, result2); } [Fact] From 651546f3a0c296b5a581bfa4a472f5bb50ba28cc Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 21 Oct 2024 21:51:57 -0400 Subject: [PATCH 047/190] Remove AIContent.ModelId, add StreamingChatCompletionUpdate.ModelId (#5535) --- .../StreamingChatCompletionUpdate.cs | 3 +++ .../Contents/AIContent.cs | 5 ----- .../AzureAIInferenceChatClient.cs | 12 +++--------- .../OllamaChatClient.cs | 9 +++++---- .../OpenAIChatClient.cs | 19 ++++++------------- .../ChatCompletion/CachingChatClient.cs | 7 +++---- .../ChatCompletion/OpenTelemetryChatClient.cs | 2 +- .../ChatCompletion/ChatMessageTests.cs | 12 ------------ .../Contents/AIContentTests.cs | 5 ----- .../Contents/DataContentTests{T}.cs | 1 - .../Contents/FunctionCallContentTests..cs | 10 ++-------- .../Contents/FunctionResultContentTests.cs | 6 ------ .../Contents/TextContentTests.cs | 5 ----- .../Contents/UsageContentTests.cs | 2 -- .../AzureAIInferenceChatClientTests.cs | 4 ++-- .../OllamaChatClientTests.cs | 2 +- .../OpenAIChatClientTests.cs | 4 ++-- .../DistributedCachingChatClientTest.cs | 5 +---- 18 files changed, 29 insertions(+), 84 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs index 8192e017f7e..278d875258a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs @@ -91,6 +91,9 @@ public IList Contents /// Gets or sets the finish reason for the operation. public ChatFinishReason? FinishReason { get; set; } + /// Gets or sets the model ID using in the creation of the chat completion of which this update is a part. + public string? ModelId { get; set; } + /// public override string ToString() => Text ?? string.Empty; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs index 456ee4940c2..29fd405b947 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -32,11 +32,6 @@ protected AIContent() [JsonIgnore] public object? RawRepresentation { get; set; } - /// - /// Gets or sets the model ID used to generate the content. - /// - public string? ModelId { get; set; } - /// Gets or sets additional properties for the content. public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 784e0388a1b..23d98dfd4bb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -97,7 +97,6 @@ public async Task CompleteAsync( if (toolCall is ChatCompletionsFunctionToolCall ftc && !string.IsNullOrWhiteSpace(ftc.Name)) { FunctionCallContent callContent = ParseCallContentFromJsonString(ftc.Arguments, toolCall.Id, ftc.Name); - callContent.ModelId = response.Model; callContent.RawRepresentation = toolCall; returnMessage.Contents.Add(callContent); @@ -109,7 +108,6 @@ public async Task CompleteAsync( { returnMessage.Contents.Add(new TextContent(choice.Message.Content) { - ModelId = response.Model, RawRepresentation = choice.Message }); } @@ -173,6 +171,7 @@ public async IAsyncEnumerable CompleteStreamingAs CompletionId = chatCompletionUpdate.Id, CreatedAt = chatCompletionUpdate.Created, FinishReason = finishReason, + ModelId = modelId, RawRepresentation = chatCompletionUpdate, Role = streamedRole, }; @@ -180,10 +179,7 @@ public async IAsyncEnumerable CompleteStreamingAs // Transfer over content update items. if (chatCompletionUpdate.ContentUpdate is string update) { - completionUpdate.Contents.Add(new TextContent(update) - { - ModelId = modelId, - }); + completionUpdate.Contents.Add(new TextContent(update)); } // Transfer over tool call updates. @@ -218,6 +214,7 @@ public async IAsyncEnumerable CompleteStreamingAs CompletionId = completionId, CreatedAt = createdAt, FinishReason = finishReason, + ModelId = modelId, Role = streamedRole, }; @@ -230,9 +227,6 @@ public async IAsyncEnumerable CompleteStreamingAs fci.Arguments?.ToString() ?? string.Empty, fci.CallId!, fci.Name!); - - callContent.ModelId = modelId; - completionUpdate.Contents.Add(callContent); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 22ff6db6dab..d37a0a3f85c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -125,24 +125,25 @@ public async IAsyncEnumerable CompleteStreamingAs continue; } + string? modelId = chunk.Model ?? Metadata.ModelId; + StreamingChatCompletionUpdate update = new() { Role = chunk.Message?.Role is not null ? new ChatRole(chunk.Message.Role) : null, CreatedAt = DateTimeOffset.TryParse(chunk.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, AdditionalProperties = ParseOllamaChatResponseProps(chunk), FinishReason = ToFinishReason(chunk), + ModelId = modelId, }; - string? modelId = chunk.Model ?? Metadata.ModelId; - if (chunk.Message is { } message) { - update.Contents.Add(new TextContent(message.Content) { ModelId = modelId }); + update.Contents.Add(new TextContent(message.Content)); } if (ParseOllamaChatResponseUsage(chunk) is { } usage) { - update.Contents.Add(new UsageContent(usage) { ModelId = modelId }); + update.Contents.Add(new UsageContent(usage)); } yield return update; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 50c9b43c58b..d97011b3e27 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -111,7 +111,7 @@ public async Task CompleteAsync( // Populate its content from those in the OpenAI response content. foreach (ChatMessageContentPart contentPart in response.Content) { - if (ToAIContent(contentPart, response.Model) is AIContent aiContent) + if (ToAIContent(contentPart) is AIContent aiContent) { returnMessage.Contents.Add(aiContent); } @@ -125,7 +125,6 @@ public async Task CompleteAsync( if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) { var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); - callContent.ModelId = response.Model; callContent.RawRepresentation = toolCall; returnMessage.Contents.Add(callContent); @@ -214,6 +213,7 @@ public async IAsyncEnumerable CompleteStreamingAs CompletionId = chatCompletionUpdate.CompletionId, CreatedAt = chatCompletionUpdate.CreatedAt, FinishReason = finishReason, + ModelId = modelId, RawRepresentation = chatCompletionUpdate, Role = streamedRole, }; @@ -239,7 +239,7 @@ public async IAsyncEnumerable CompleteStreamingAs { foreach (ChatMessageContentPart contentPart in chatCompletionUpdate.ContentUpdate) { - if (ToAIContent(contentPart, modelId) is AIContent aiContent) + if (ToAIContent(contentPart) is AIContent aiContent) { completionUpdate.Contents.Add(aiContent); } @@ -292,10 +292,7 @@ public async IAsyncEnumerable CompleteStreamingAs // TODO: Add support for prompt token details (e.g. cached tokens) once it's exposed in OpenAI library. - completionUpdate.Contents.Add(new UsageContent(usageDetails) - { - ModelId = modelId - }); + completionUpdate.Contents.Add(new UsageContent(usageDetails)); } // Now yield the item. @@ -310,6 +307,7 @@ public async IAsyncEnumerable CompleteStreamingAs CompletionId = completionId, CreatedAt = createdAt, FinishReason = finishReason, + ModelId = modelId, Role = streamedRole, }; @@ -322,9 +320,6 @@ public async IAsyncEnumerable CompleteStreamingAs fci.Arguments?.ToString() ?? string.Empty, fci.CallId!, fci.Name!); - - callContent.ModelId = modelId; - completionUpdate.Contents.Add(callContent); } } @@ -531,9 +526,8 @@ private sealed class OpenAIChatToolJson /// Creates an from a . /// The content part to convert into a content. - /// The model ID. /// The constructed , or null if the content part could not be converted. - private static AIContent? ToAIContent(ChatMessageContentPart contentPart, string? modelId) + private static AIContent? ToAIContent(ChatMessageContentPart contentPart) { AIContent? aiContent = null; @@ -564,7 +558,6 @@ private sealed class OpenAIChatToolJson (additionalProperties ??= [])[nameof(contentPart.Refusal)] = refusal; } - aiContent.ModelId = modelId; aiContent.AdditionalProperties = additionalProperties; aiContent.RawRepresentation = contentPart; } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs index a12061a1028..ad620346172 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs @@ -130,7 +130,6 @@ next.Contents[0] is not TextContent || TextContent coalescedContent = new(null) // will patch the text after examining all items in the run { AdditionalProperties = textContent.AdditionalProperties?.Clone(), - ModelId = textContent.ModelId, }; StreamingChatCompletionUpdate coalesced = new() @@ -141,6 +140,7 @@ next.Contents[0] is not TextContent || Contents = [coalescedContent], CreatedAt = update.CreatedAt, FinishReason = update.FinishReason, + ModelId = update.ModelId, Role = update.Role, // Explicitly don't include RawRepresentation. It's not applicable if one update ends up being used @@ -160,16 +160,15 @@ next.Contents[0] is not TextContent || StreamingChatCompletionUpdate next = capturedItems[i]; capturedItems[i] = null!; - TextContent nextContent = (TextContent)next.Contents[0]; + var nextContent = (TextContent)next.Contents[0]; _ = coalescedText.Append(nextContent.Text); coalesced.AuthorName ??= next.AuthorName; coalesced.CompletionId ??= next.CompletionId; coalesced.CreatedAt ??= next.CreatedAt; coalesced.FinishReason ??= next.FinishReason; + coalesced.ModelId ??= next.ModelId; coalesced.Role ??= next.Role; - - coalescedContent.ModelId ??= nextContent.ModelId; } // Complete the coalescing by patching the text of the coalesced node. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index 46e26bea181..905e756e246 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -230,7 +230,7 @@ private static ChatCompletion ComposeStreamingUpdatesIntoChatCompletion( finishReason ??= update.FinishReason; role ??= update.Role; items.AddRange(update.Contents); - modelId ??= update.Contents.FirstOrDefault(c => c.ModelId is not null)?.ModelId; + modelId ??= update.ModelId; } messages.Add(new ChatMessage(role ?? ChatRole.Assistant, items)); 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 e05e0d0ef47..31336e70674 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs @@ -262,32 +262,26 @@ public void ItCanBeSerializeAndDeserialized() [ new TextContent("content-1") { - ModelId = "model-1", AdditionalProperties = new() { ["metadata-key-1"] = "metadata-value-1" } }, new ImageContent(new Uri("https://fake-random-test-host:123"), "mime-type/2") { - ModelId = "model-2", AdditionalProperties = new() { ["metadata-key-2"] = "metadata-value-2" } }, new DataContent(new BinaryData(new[] { 1, 2, 3 }, options: TestJsonSerializerContext.Default.Options), "mime-type/3") { - ModelId = "model-3", AdditionalProperties = new() { ["metadata-key-3"] = "metadata-value-3" } }, new AudioContent(new BinaryData(new[] { 3, 2, 1 }, options: TestJsonSerializerContext.Default.Options), "mime-type/4") { - ModelId = "model-4", AdditionalProperties = new() { ["metadata-key-4"] = "metadata-value-4" } }, new ImageContent(new BinaryData(new[] { 2, 1, 3 }, options: TestJsonSerializerContext.Default.Options), "mime-type/5") { - ModelId = "model-5", AdditionalProperties = new() { ["metadata-key-5"] = "metadata-value-5" } }, new TextContent("content-6") { - ModelId = "model-6", AdditionalProperties = new() { ["metadata-key-6"] = "metadata-value-6" } }, new FunctionCallContent("function-id", "plugin-name-function-name", new Dictionary { ["parameter"] = "argument" }), @@ -317,7 +311,6 @@ public void ItCanBeSerializeAndDeserialized() var textContent = deserializedMessage.Contents[0] as TextContent; Assert.NotNull(textContent); Assert.Equal("content-1-override", textContent.Text); - Assert.Equal("model-1", textContent.ModelId); Assert.NotNull(textContent.AdditionalProperties); Assert.Single(textContent.AdditionalProperties); Assert.Equal("metadata-value-1", textContent.AdditionalProperties["metadata-key-1"]?.ToString()); @@ -325,7 +318,6 @@ public void ItCanBeSerializeAndDeserialized() var imageContent = deserializedMessage.Contents[1] as ImageContent; Assert.NotNull(imageContent); Assert.Equal("https://fake-random-test-host:123/", imageContent.Uri); - Assert.Equal("model-2", imageContent.ModelId); Assert.Equal("mime-type/2", imageContent.MediaType); Assert.NotNull(imageContent.AdditionalProperties); Assert.Single(imageContent.AdditionalProperties); @@ -334,7 +326,6 @@ public void ItCanBeSerializeAndDeserialized() var dataContent = deserializedMessage.Contents[2] as DataContent; Assert.NotNull(dataContent); Assert.True(dataContent.Data!.Value.Span.SequenceEqual(new BinaryData(new[] { 1, 2, 3 }, TestJsonSerializerContext.Default.Options))); - Assert.Equal("model-3", dataContent.ModelId); Assert.Equal("mime-type/3", dataContent.MediaType); Assert.NotNull(dataContent.AdditionalProperties); Assert.Single(dataContent.AdditionalProperties); @@ -343,7 +334,6 @@ public void ItCanBeSerializeAndDeserialized() var audioContent = deserializedMessage.Contents[3] as AudioContent; Assert.NotNull(audioContent); Assert.True(audioContent.Data!.Value.Span.SequenceEqual(new BinaryData(new[] { 3, 2, 1 }, TestJsonSerializerContext.Default.Options))); - Assert.Equal("model-4", audioContent.ModelId); Assert.Equal("mime-type/4", audioContent.MediaType); Assert.NotNull(audioContent.AdditionalProperties); Assert.Single(audioContent.AdditionalProperties); @@ -352,7 +342,6 @@ public void ItCanBeSerializeAndDeserialized() imageContent = deserializedMessage.Contents[4] as ImageContent; Assert.NotNull(imageContent); Assert.True(imageContent.Data?.Span.SequenceEqual(new BinaryData(new[] { 2, 1, 3 }, TestJsonSerializerContext.Default.Options))); - Assert.Equal("model-5", imageContent.ModelId); Assert.Equal("mime-type/5", imageContent.MediaType); Assert.NotNull(imageContent.AdditionalProperties); Assert.Single(imageContent.AdditionalProperties); @@ -361,7 +350,6 @@ public void ItCanBeSerializeAndDeserialized() textContent = deserializedMessage.Contents[5] as TextContent; Assert.NotNull(textContent); Assert.Equal("content-6", textContent.Text); - Assert.Equal("model-6", textContent.ModelId); Assert.NotNull(textContent.AdditionalProperties); Assert.Single(textContent.AdditionalProperties); Assert.Equal("metadata-value-6", textContent.AdditionalProperties["metadata-key-6"]?.ToString()); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs index ece02f017bb..027fd61649c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs @@ -12,7 +12,6 @@ public void Constructor_PropsDefault() { DerivedAIContent c = new(); Assert.Null(c.RawRepresentation); - Assert.Null(c.ModelId); Assert.Null(c.AdditionalProperties); } @@ -26,10 +25,6 @@ public void Constructor_PropsRoundtrip() c.RawRepresentation = raw; Assert.Same(raw, c.RawRepresentation); - Assert.Null(c.ModelId); - c.ModelId = "modelId"; - Assert.Equal("modelId", c.ModelId); - Assert.Null(c.AdditionalProperties); AdditionalPropertiesDictionary props = new() { { "key", "value" } }; c.AdditionalProperties = props; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs index ea3017cf7ea..b34f6da0255 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs @@ -192,7 +192,6 @@ public void Deserialize_MatchesExpectedData() Assert.Equal([0x01, 0x02, 0x03, 0x04], content.Data!.Value.ToArray()); Assert.Equal("text/plain", content.MediaType); Assert.True(content.ContainsData); - Assert.Equal("gpt-4", content.ModelId); Assert.Equal("value", content.AdditionalProperties!["key"]!.ToString()); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs index 50ca205197d..49ff719f8b5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs @@ -21,7 +21,6 @@ public void Constructor_PropsDefault() FunctionCallContent c = new("callId1", "name"); Assert.Null(c.RawRepresentation); - Assert.Null(c.ModelId); Assert.Null(c.AdditionalProperties); Assert.Equal("callId1", c.CallId); @@ -39,7 +38,6 @@ public void Constructor_ArgumentsRoundtrip() FunctionCallContent c = new("id", "name", args); Assert.Null(c.RawRepresentation); - Assert.Null(c.ModelId); Assert.Null(c.AdditionalProperties); Assert.Equal("name", c.Name); @@ -57,10 +55,6 @@ public void Constructor_PropsRoundtrip() c.RawRepresentation = raw; Assert.Same(raw, c.RawRepresentation); - Assert.Null(c.ModelId); - c.ModelId = "modelId"; - Assert.Equal("modelId", c.ModelId); - Assert.Null(c.AdditionalProperties); AdditionalPropertiesDictionary props = new() { { "key", "value" } }; c.AdditionalProperties = props; @@ -322,8 +316,8 @@ public static void CreateFromParsedArguments_ObjectJsonInput_ReturnsElementArgum [InlineData(typeof(NotSupportedException))] public static void CreateFromParsedArguments_ParseException_HasExpectedHandling(Type exceptionType) { - Exception exc = (Exception)Activator.CreateInstance(exceptionType)!; - FunctionCallContent content = FunctionCallContent.CreateFromParsedArguments(exc, "callId", "functionName", ThrowingParser); + var exc = (Exception)Activator.CreateInstance(exceptionType)!; + var content = FunctionCallContent.CreateFromParsedArguments(exc, "callId", "functionName", ThrowingParser); Assert.Equal("functionName", content.Name); Assert.Equal("callId", content.CallId); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs index 10a23c69596..ef3382b430e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs @@ -16,7 +16,6 @@ public void Constructor_PropsDefault() Assert.Equal("callId1", c.CallId); Assert.Equal("functionName", c.Name); Assert.Null(c.RawRepresentation); - Assert.Null(c.ModelId); Assert.Null(c.AdditionalProperties); Assert.Null(c.Result); Assert.Null(c.Exception); @@ -27,7 +26,6 @@ public void Constructor_String_PropsRoundtrip() { FunctionResultContent c = new("id", "name", "result"); Assert.Null(c.RawRepresentation); - Assert.Null(c.ModelId); Assert.Null(c.AdditionalProperties); Assert.Equal("name", c.Name); Assert.Equal("id", c.CallId); @@ -45,10 +43,6 @@ public void Constructor_PropsRoundtrip() c.RawRepresentation = raw; Assert.Same(raw, c.RawRepresentation); - Assert.Null(c.ModelId); - c.ModelId = "modelId"; - Assert.Equal("modelId", c.ModelId); - Assert.Null(c.AdditionalProperties); AdditionalPropertiesDictionary props = new() { { "key", "value" } }; c.AdditionalProperties = props; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs index d1ba5e83bc9..456867a2649 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs @@ -15,7 +15,6 @@ public void Constructor_String_PropsDefault(string? text) { TextContent c = new(text); Assert.Null(c.RawRepresentation); - Assert.Null(c.ModelId); Assert.Null(c.AdditionalProperties); Assert.Equal(text, c.Text); } @@ -30,10 +29,6 @@ public void Constructor_PropsRoundtrip() c.RawRepresentation = raw; Assert.Same(raw, c.RawRepresentation); - Assert.Null(c.ModelId); - c.ModelId = "modelId"; - Assert.Equal("modelId", c.ModelId); - Assert.Null(c.AdditionalProperties); AdditionalPropertiesDictionary props = new() { { "key", "value" } }; c.AdditionalProperties = props; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs index 109bdc8120e..2314cd66f93 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs @@ -19,7 +19,6 @@ public void Constructor_Parameterless_PropsDefault() { UsageContent c = new(); Assert.Null(c.RawRepresentation); - Assert.Null(c.ModelId); Assert.Null(c.AdditionalProperties); Assert.NotNull(c.Details); @@ -37,7 +36,6 @@ public void Constructor_UsageDetails_PropsRoundtrip() UsageContent c = new(details); Assert.Null(c.RawRepresentation); - Assert.Null(c.ModelId); Assert.Null(c.AdditionalProperties); Assert.Same(details, c.Details); diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index be628c13d0d..474ead54baf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -201,7 +201,7 @@ public async Task BasicRequestResponse_Streaming() { Assert.Equal("chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK", updates[i].CompletionId); Assert.Equal(createdAt, updates[i].CreatedAt); - Assert.All(updates[i].Contents, u => Assert.Equal("gpt-4o-mini-2024-07-18", u.ModelId)); + Assert.Equal("gpt-4o-mini-2024-07-18", updates[i].ModelId); Assert.Equal(ChatRole.Assistant, updates[i].Role); Assert.Equal(i < 10 ? 1 : 0, updates[i].Contents.Count); Assert.Equal(i < 10 ? null : ChatFinishReason.Stop, updates[i].FinishReason); @@ -516,7 +516,7 @@ public async Task FunctionCallContent_Streaming() { Assert.Equal("chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl", updates[i].CompletionId); Assert.Equal(createdAt, updates[i].CreatedAt); - Assert.All(updates[i].Contents, u => Assert.Equal("gpt-4o-mini-2024-07-18", u.ModelId)); + Assert.Equal("gpt-4o-mini-2024-07-18", updates[i].ModelId); Assert.Equal(ChatRole.Assistant, updates[i].Role); Assert.Equal(i < 7 ? null : ChatFinishReason.ToolCalls, updates[i].FinishReason); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs index b09947337ed..22fa54391cc 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs @@ -172,7 +172,7 @@ public async Task BasicRequestResponse_Streaming() { Assert.Equal(i < updates.Count - 1 ? 1 : 2, updates[i].Contents.Count); Assert.Equal(ChatRole.Assistant, updates[i].Role); - Assert.All(updates[i].Contents, u => Assert.Equal("llama3.1", u.ModelId)); + 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); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index adc245c58e8..5175740dc5a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -247,7 +247,7 @@ public async Task BasicRequestResponse_Streaming() { Assert.Equal("chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK", updates[i].CompletionId); Assert.Equal(createdAt, updates[i].CreatedAt); - Assert.All(updates[i].Contents, u => Assert.Equal("gpt-4o-mini-2024-07-18", u.ModelId)); + Assert.Equal("gpt-4o-mini-2024-07-18", updates[i].ModelId); Assert.Equal(ChatRole.Assistant, updates[i].Role); Assert.NotNull(updates[i].AdditionalProperties); Assert.Equal("fp_f85bea6784", updates[i].AdditionalProperties![nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); @@ -565,7 +565,7 @@ public async Task FunctionCallContent_Streaming() { Assert.Equal("chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl", updates[i].CompletionId); Assert.Equal(createdAt, updates[i].CreatedAt); - Assert.All(updates[i].Contents, u => Assert.Equal("gpt-4o-mini-2024-07-18", u.ModelId)); + Assert.Equal("gpt-4o-mini-2024-07-18", updates[i].ModelId); Assert.Equal(ChatRole.Assistant, updates[i].Role); Assert.NotNull(updates[i].AdditionalProperties); Assert.Equal("fp_f85bea6784", updates[i].AdditionalProperties![nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs index 158c55aee7a..7f6ca20915e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs @@ -330,7 +330,7 @@ public async Task StreamingCoalescingPropagatesMetadataAsync() List expectedCompletion = [ new() { Role = ChatRole.Assistant, Contents = [new TextContent("Hello")] }, - new() { Role = ChatRole.Assistant, Contents = [new TextContent(" world, ") { ModelId = "some model" }] }, + new() { Role = ChatRole.Assistant, Contents = [new TextContent(" world, ")] }, new() { Role = ChatRole.Assistant, @@ -338,7 +338,6 @@ public async Task StreamingCoalescingPropagatesMetadataAsync() [ new TextContent("how ") { - ModelId = "some other model", AdditionalProperties = new() { ["a"] = "b", ["c"] = "d" }, } ] @@ -386,7 +385,6 @@ public async Task StreamingCoalescingPropagatesMetadataAsync() var content = Assert.IsType(Assert.Single(item.Contents)); Assert.Equal("Hello world, how are you?", content.Text); - Assert.Equal("some model", content.ModelId); } [Fact] @@ -717,7 +715,6 @@ private static void AssertCompletionsEqual(ChatCompletion expected, ChatCompleti { var expectedItem = expected.Choices[i].Contents[itemIndex]; var actualItem = actual.Choices[i].Contents[itemIndex]; - Assert.Equal(expectedItem.ModelId, actualItem.ModelId); Assert.IsType(expectedItem.GetType(), actualItem); if (expectedItem is FunctionCallContent expectedFcc) From aa63ac73f8f281e159c3913f2ef1269057c13d6c Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 22 Oct 2024 05:46:18 -0400 Subject: [PATCH 048/190] Fix embedding integration test after telemetry updates (#5555) --- .../EmbeddingGeneratorIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs index 1929869c487..806cf63e017 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs @@ -118,7 +118,7 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() Assert.Single(activities); var activity = activities.Single(); - Assert.StartsWith("embedding", activity.DisplayName); + Assert.StartsWith("embed", activity.DisplayName); Assert.StartsWith("http", (string)activity.GetTagItem("server.address")!); Assert.Equal(embeddingGenerator.Metadata.ProviderUri?.Port, (int)activity.GetTagItem("server.port")!); Assert.NotNull(activity.Id); From ccd86d9046c5b2260ae38a8824e744aa771095e7 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 22 Oct 2024 05:57:37 -0400 Subject: [PATCH 049/190] Lower M.E.AI.Ollama STJ dependency back to 8 (#5554) --- .../Microsoft.Extensions.AI.Ollama.csproj | 2 -- .../OllamaChatClient.cs | 13 ++++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj index 416eeeca6e0..81beb0d7bed 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj @@ -4,8 +4,6 @@ Microsoft.Extensions.AI Implementation of generative AI abstractions for Ollama. AI - - true diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index d37a0a3f85c..1bb4dc4e5fb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Json; @@ -114,12 +115,14 @@ public async IAsyncEnumerable CompleteStreamingAs #endif .ConfigureAwait(false); - await foreach (OllamaChatResponse? chunk in JsonSerializer.DeserializeAsyncEnumerable( - httpResponseStream, - JsonContext.Default.OllamaChatResponse, - topLevelValues: true, - cancellationToken).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; From 8fbeca093277ca129acdc860b66a04670d9d98df Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 22 Oct 2024 05:58:03 -0400 Subject: [PATCH 050/190] Add missing [JsonIgnore] on ChatCompletion.Message (#5552) This property is an accelerator into Choices. It's already serialized as part of Choices and shouldn't be duplicated. --- .../ChatCompletion/ChatCompletion.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs index 729483e7c30..0d3d28bd86b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs @@ -42,6 +42,7 @@ public IList Choices /// If there are multiple choices, this property returns the first choice. /// If is empty, this will throw. Use to access all choices directly."/>. /// + [JsonIgnore] public ChatMessage Message { get From 2dd959f508642fa4d067f475764c08159abaa373 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 22 Oct 2024 05:58:28 -0400 Subject: [PATCH 051/190] Add OllamaChatClient ctor with string endpoint (#5553) --- .../OllamaChatClient.cs | 12 ++++++++++++ .../OllamaChatClientTests.cs | 16 ++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 1bb4dc4e5fb..72ddb13b2ac 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -30,6 +30,18 @@ public sealed class OllamaChatClient : IChatClient /// 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 may 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. /// diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs index 22fa54391cc..3e281173c8b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs @@ -22,14 +22,14 @@ public class OllamaChatClientTests [Fact] public void Ctor_InvalidArgs_Throws() { - Assert.Throws("endpoint", () => new OllamaChatClient(null!)); - Assert.Throws("modelId", () => new OllamaChatClient(new("http://localhost"), " ")); + Assert.Throws("endpoint", () => new OllamaChatClient((Uri)null!)); + Assert.Throws("modelId", () => new OllamaChatClient("http://localhost", " ")); } [Fact] public void GetService_SuccessfullyReturnsUnderlyingClient() { - using OllamaChatClient client = new(new("http://localhost")); + using OllamaChatClient client = new("http://localhost"); Assert.Same(client, client.GetService()); Assert.Same(client, client.GetService()); @@ -94,7 +94,7 @@ public async Task BasicRequestResponse_NonStreaming() using VerbatimHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using OllamaChatClient client = new(new("http://localhost:11434"), "llama3.1", httpClient); + using OllamaChatClient client = new("http://localhost:11434", "llama3.1", httpClient); var response = await client.CompleteAsync("hello", new() { MaxOutputTokens = 10, @@ -152,7 +152,7 @@ public async Task BasicRequestResponse_Streaming() using VerbatimHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using IChatClient client = new OllamaChatClient(new("http://localhost:11434"), "llama3.1", httpClient); + using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient); List updates = []; await foreach (var update in client.CompleteStreamingAsync("hello", new() @@ -238,7 +238,7 @@ public async Task MultipleMessages_NonStreaming() using VerbatimHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using IChatClient client = new OllamaChatClient(new("http://localhost:11434"), httpClient: httpClient); + using IChatClient client = new OllamaChatClient("http://localhost:11434", httpClient: httpClient); List messages = [ @@ -342,7 +342,7 @@ public async Task FunctionCallContent_NonStreaming() using VerbatimHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler) { Timeout = Timeout.InfiniteTimeSpan }; - using IChatClient client = new OllamaChatClient(new("http://localhost:11434"), "llama3.1", httpClient) + using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient) { ToolCallJsonSerializerOptions = TestJsonSerializerContext.Default.Options, }; @@ -434,7 +434,7 @@ public async Task FunctionResultContent_NonStreaming() using VerbatimHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler) { Timeout = Timeout.InfiniteTimeSpan }; - using IChatClient client = new OllamaChatClient(new("http://localhost:11434"), "llama3.1", httpClient) + using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient) { ToolCallJsonSerializerOptions = TestJsonSerializerContext.Default.Options, }; From 7cac12be44efb0069baac843c45010a1174ed260 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 22 Oct 2024 11:10:50 -0400 Subject: [PATCH 052/190] Fix a few issues in IChatClient implementations (#5549) * Fix a few issues in IChatClient implementations - Avoid null arg exception when constructing system message with null text - Avoid empty exception when constructing user message with no parts - Use all parts rather than just first text part for system message - Handle assistant messages with both content and tools - Avoid unnecessarily trying to weed out duplicate call ids * Address PR feedback - Normalize null to string.Empty in TextContent - Ensure GetContentParts always produces at least one part, even if empty text content --- .../Contents/TextContent.cs | 15 +- .../AzureAIInferenceChatClient.cs | 75 ++-- .../OpenAIChatClient.cs | 66 ++-- .../Contents/TextContentTests.cs | 10 +- .../AzureAIInferenceChatClientTests.cs | 83 +++++ .../ReducingChatClientTests.cs | 4 +- .../OpenAIChatClientTests.cs | 321 ++++++++++++++++++ 7 files changed, 510 insertions(+), 64 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs index d81e969e1c4..4c545084502 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.Extensions.AI; /// @@ -8,20 +10,27 @@ namespace Microsoft.Extensions.AI; /// public sealed class TextContent : AIContent { + private string? _text; + /// /// Initializes a new instance of the class. /// /// The text content. public TextContent(string? text) { - Text = text; + _text = text; } /// /// Gets or sets the text content. /// - public string? Text { get; set; } + [AllowNull] + public string Text + { + get => _text ?? string.Empty; + set => _text = value; + } /// - public override string ToString() => Text ?? string.Empty; + public override string ToString() => Text; } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 23d98dfd4bb..125449689c4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -410,13 +409,13 @@ private sealed class AzureAIChatToolJson private IEnumerable ToAzureAIInferenceChatMessages(IEnumerable inputs) { // Maps all of the M.E.AI types to the corresponding AzureAI types. - // Unrecognized content is ignored. + // Unrecognized or non-processable content is ignored. foreach (ChatMessage input in inputs) { if (input.Role == ChatRole.System) { - yield return new ChatRequestSystemMessage(input.Text); + yield return new ChatRequestSystemMessage(input.Text ?? string.Empty); } else if (input.Role == ChatRole.Tool) { @@ -444,52 +443,64 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab } else if (input.Role == ChatRole.User) { - yield return new ChatRequestUserMessage(input.Contents.Select(static (AIContent item) => item switch - { - TextContent textContent => new ChatMessageTextContentItem(textContent.Text), - ImageContent imageContent => imageContent.Data is { IsEmpty: false } data ? new ChatMessageImageContentItem(BinaryData.FromBytes(data), imageContent.MediaType) : - imageContent.Uri is string uri ? new ChatMessageImageContentItem(new Uri(uri)) : - (ChatMessageContentItem?)null, - _ => null, - }).Where(c => c is not null)); + yield return new ChatRequestUserMessage(GetContentParts(input.Contents)); } else if (input.Role == ChatRole.Assistant) { - Dictionary? toolCalls = null; + // TODO: ChatRequestAssistantMessage only enables text content currently. + // Update it with other content types when it supports that. + ChatRequestAssistantMessage message = new() + { + Content = input.Text + }; foreach (var content in input.Contents) { - if (content is FunctionCallContent callRequest && callRequest.CallId is not null && toolCalls?.ContainsKey(callRequest.CallId) is not true) + if (content is FunctionCallContent { CallId: not null } callRequest) { JsonSerializerOptions serializerOptions = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; - string jsonArguments = JsonSerializer.Serialize(callRequest.Arguments, serializerOptions.GetTypeInfo(typeof(IDictionary))); - (toolCalls ??= []).Add( + message.ToolCalls.Add(new ChatCompletionsFunctionToolCall( callRequest.CallId, - new ChatCompletionsFunctionToolCall( - callRequest.CallId, - callRequest.Name, - jsonArguments)); + callRequest.Name, + JsonSerializer.Serialize(callRequest.Arguments, serializerOptions.GetTypeInfo(typeof(IDictionary))))); } } - ChatRequestAssistantMessage message = new(); - if (toolCalls is not null) - { - foreach (var entry in toolCalls) - { - message.ToolCalls.Add(entry.Value); - } - } - else - { - message.Content = input.Text; - } - yield return message; } } } + /// Converts a list of to a list of . + private static List GetContentParts(IList contents) + { + List parts = []; + foreach (var content in contents) + { + switch (content) + { + case TextContent textContent: + (parts ??= []).Add(new ChatMessageTextContentItem(textContent.Text)); + break; + + case ImageContent imageContent when imageContent.Data is { IsEmpty: false } data: + (parts ??= []).Add(new ChatMessageImageContentItem(BinaryData.FromBytes(data), imageContent.MediaType)); + break; + + case ImageContent imageContent when imageContent.Uri is string uri: + (parts ??= []).Add(new ChatMessageImageContentItem(new Uri(uri))); + break; + } + } + + if (parts.Count == 0) + { + parts.Add(new ChatMessageTextContentItem(string.Empty)); + } + + return parts; + } + private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(json, callId, name, argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index d97011b3e27..6bcf83a7616 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -569,13 +568,16 @@ private sealed class OpenAIChatToolJson private IEnumerable ToOpenAIChatMessages(IEnumerable inputs) { // Maps all of the M.E.AI types to the corresponding OpenAI types. - // Unrecognized content is ignored. + // Unrecognized or non-processable content is ignored. foreach (ChatMessage input in inputs) { - if (input.Role == ChatRole.System) + if (input.Role == ChatRole.System || input.Role == ChatRole.User) { - yield return new SystemChatMessage(input.Text) { ParticipantName = input.AuthorName }; + var parts = GetContentParts(input.Contents); + yield return input.Role == ChatRole.System ? + new SystemChatMessage(parts) { ParticipantName = input.AuthorName } : + new UserChatMessage(parts) { ParticipantName = input.AuthorName }; } else if (input.Role == ChatRole.Tool) { @@ -601,28 +603,18 @@ private sealed class OpenAIChatToolJson } } } - else if (input.Role == ChatRole.User) - { - yield return new UserChatMessage(input.Contents.Select(static (AIContent item) => item switch - { - TextContent textContent => ChatMessageContentPart.CreateTextPart(textContent.Text), - ImageContent imageContent => imageContent.Data is { IsEmpty: false } data ? ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(data), imageContent.MediaType) : - imageContent.Uri is string uri ? ChatMessageContentPart.CreateImagePart(new Uri(uri)) : - null, - _ => null, - }).Where(c => c is not null)) - { ParticipantName = input.AuthorName }; - } else if (input.Role == ChatRole.Assistant) { - Dictionary? toolCalls = null; + AssistantChatMessage message = new(GetContentParts(input.Contents)) + { + ParticipantName = input.AuthorName + }; foreach (var content in input.Contents) { - if (content is FunctionCallContent callRequest && callRequest.CallId is not null && toolCalls?.ContainsKey(callRequest.CallId) is not true) + if (content is FunctionCallContent { CallId: not null } callRequest) { - (toolCalls ??= []).Add( - callRequest.CallId, + message.ToolCalls.Add( ChatToolCall.CreateFunctionToolCall( callRequest.CallId, callRequest.Name, @@ -630,10 +622,6 @@ private sealed class OpenAIChatToolJson } } - AssistantChatMessage message = toolCalls is not null ? - new(toolCalls.Values) { ParticipantName = input.AuthorName } : - new(input.Text) { ParticipantName = input.AuthorName }; - if (input.AdditionalProperties?.TryGetValue(nameof(message.Refusal), out string? refusal) is true) { message.Refusal = refusal; @@ -644,6 +632,36 @@ private sealed class OpenAIChatToolJson } } + /// Converts a list of to a list of . + private static List GetContentParts(IList contents) + { + List parts = []; + foreach (var content in contents) + { + switch (content) + { + case TextContent textContent: + (parts ??= []).Add(ChatMessageContentPart.CreateTextPart(textContent.Text)); + break; + + case ImageContent imageContent when imageContent.Data is { IsEmpty: false } data: + (parts ??= []).Add(ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(data), imageContent.MediaType)); + break; + + case ImageContent imageContent when imageContent.Uri is string uri: + (parts ??= []).Add(ChatMessageContentPart.CreateImagePart(new Uri(uri))); + break; + } + } + + if (parts.Count == 0) + { + parts.Add(ChatMessageContentPart.CreateTextPart(string.Empty)); + } + + return parts; + } + private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(json, callId, name, argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs index 456867a2649..97afc4208e7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs @@ -16,7 +16,7 @@ public void Constructor_String_PropsDefault(string? text) TextContent c = new(text); Assert.Null(c.RawRepresentation); Assert.Null(c.AdditionalProperties); - Assert.Equal(text, c.Text); + Assert.Equal(text ?? string.Empty, c.Text); } [Fact] @@ -34,13 +34,17 @@ public void Constructor_PropsRoundtrip() c.AdditionalProperties = props; Assert.Same(props, c.AdditionalProperties); - Assert.Null(c.Text); + Assert.Equal(string.Empty, c.Text); c.Text = "text"; Assert.Equal("text", c.Text); Assert.Equal("text", c.ToString()); c.Text = null; - Assert.Null(c.Text); + Assert.Equal(string.Empty, c.Text); + Assert.Equal(string.Empty, c.ToString()); + + c.Text = string.Empty; + Assert.Equal(string.Empty, c.Text); Assert.Equal(string.Empty, c.ToString()); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 474ead54baf..9a860014b8f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -321,6 +321,89 @@ public async Task MultipleMessages_NonStreaming() Assert.Equal(57, response.Usage.TotalTokenCount); } + [Fact] + public async Task NullAssistantText_ContentSkipped_NonStreaming() + { + const string Input = """ + { + "messages": [ + { + "role": "assistant" + }, + { + "content": [ + { + "text": "hello!", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "gpt-4o-mini" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello.", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 42, + "completion_tokens": 15, + "total_tokens": 57, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List messages = + [ + new(ChatRole.Assistant, (string?)null), + new(ChatRole.User, "hello!"), + ]; + + var response = await client.CompleteAsync(messages); + Assert.NotNull(response); + + Assert.Equal("chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", response.CompletionId); + Assert.Equal("Hello.", response.Message.Text); + Assert.Single(response.Message.Contents); + Assert.Equal(ChatRole.Assistant, response.Message.Role); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_727_894_187), response.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + + Assert.NotNull(response.Usage); + Assert.Equal(42, response.Usage.InputTokenCount); + Assert.Equal(15, response.Usage.OutputTokenCount); + Assert.Equal(57, response.Usage.TotalTokenCount); + } + [Fact] public async Task FunctionCallContent_NonStreaming() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs index 0c436f7ccb5..684211ab60b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs @@ -190,9 +190,9 @@ private int CountTokens(ChatMessage message) int sum = 0; foreach (AIContent content in message.Contents) { - if ((content as TextContent)?.Text is string text) + if (content is TextContent text) { - sum += _tokenizer.CountTokens(text); + sum += _tokenizer.CountTokens(text.Text); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 5175740dc5a..691804e5fb8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -370,6 +370,191 @@ public async Task MultipleMessages_NonStreaming() Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); } + [Fact] + public async Task MultiPartSystemMessage_NonStreaming() + { + const string Input = """ + { + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a really nice friend." + }, + { + "type": "text", + "text": "Really nice." + } + ] + }, + { + "role": "user", + "content": "hello!" + } + ], + "model": "gpt-4o-mini" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hi! It's so good to hear from you!", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 42, + "completion_tokens": 15, + "total_tokens": 57, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List messages = + [ + new(ChatRole.System, [new TextContent("You are a really nice friend."), new TextContent("Really nice.")]), + new(ChatRole.User, "hello!"), + ]; + + var response = await client.CompleteAsync(messages); + Assert.NotNull(response); + + Assert.Equal("chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", response.CompletionId); + Assert.Equal("Hi! It's so good to hear from you!", response.Message.Text); + Assert.Single(response.Message.Contents); + Assert.Equal(ChatRole.Assistant, response.Message.Role); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_727_894_187), response.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + + Assert.NotNull(response.Usage); + Assert.Equal(42, response.Usage.InputTokenCount); + Assert.Equal(15, response.Usage.OutputTokenCount); + Assert.Equal(57, response.Usage.TotalTokenCount); + Assert.NotNull(response.Usage.AdditionalProperties); + + Assert.NotNull(response.AdditionalProperties); + Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); + } + + [Fact] + public async Task EmptyAssistantMessage_NonStreaming() + { + const string Input = """ + { + "messages": [ + { + "role": "system", + "content": "You are a really nice friend." + }, + { + "role": "user", + "content": "hello!" + }, + { + "role": "assistant", + "content": "" + }, + { + "role": "user", + "content": "i\u0027m good. how are you?" + } + ], + "model": "gpt-4o-mini" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I’m doing well, thank you! What’s on your mind today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 42, + "completion_tokens": 15, + "total_tokens": 57, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List messages = + [ + new(ChatRole.System, "You are a really nice friend."), + new(ChatRole.User, "hello!"), + new(ChatRole.Assistant, (string?)null), + new(ChatRole.User, "i'm good. how are you?"), + ]; + + var response = await client.CompleteAsync(messages); + Assert.NotNull(response); + + Assert.Equal("chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", response.CompletionId); + Assert.Equal("I’m doing well, thank you! What’s on your mind today?", response.Message.Text); + Assert.Single(response.Message.Contents); + Assert.Equal(ChatRole.Assistant, response.Message.Role); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_727_894_187), response.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + + Assert.NotNull(response.Usage); + Assert.Equal(42, response.Usage.InputTokenCount); + Assert.Equal(15, response.Usage.OutputTokenCount); + Assert.Equal(57, response.Usage.TotalTokenCount); + Assert.NotNull(response.Usage.AdditionalProperties); + + Assert.NotNull(response.AdditionalProperties); + Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); + } + [Fact] public async Task FunctionCallContent_NonStreaming() { @@ -585,6 +770,142 @@ public async Task FunctionCallContent_Streaming() Assert.Equal(new Dictionary { [nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)] = 0 }, usage.Details.AdditionalProperties[nameof(ChatTokenUsage.OutputTokenDetails)]); } + [Fact] + public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() + { + const string Input = """ + { + "messages": [ + { + "role": "system", + "content": "You are a really nice friend." + }, + { + "role": "user", + "content": "hello!" + }, + { + "role": "assistant", + "content": "hi, how are you?", + "tool_calls": [ + { + "id": "12345", + "type": "function", + "function": { + "name": "SayHello", + "arguments": "null" + } + }, + { + "id": "12346", + "type": "function", + "function": { + "name": "SayHi", + "arguments": "null" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "12345", + "content": "Said hello" + }, + { + "role":"tool", + "tool_call_id":"12346", + "content":"Said hi" + }, + { + "role":"assistant", + "content":"You are great." + }, + { + "role":"user", + "content":"Thanks!" + } + ], + "model":"gpt-4o-mini" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", + "object": "chat.completion", + "created": 1727894187, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I’m doing well, thank you! What’s on your mind today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 42, + "completion_tokens": 15, + "total_tokens": 57, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0 + } + }, + "system_fingerprint": "fp_f85bea6784" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List messages = + [ + new(ChatRole.System, "You are a really nice friend."), + new(ChatRole.User, "hello!"), + new(ChatRole.Assistant, + [ + new TextContent("hi, how are you?"), + new FunctionCallContent("12345", "SayHello"), + new FunctionCallContent("12346", "SayHi"), + ]), + new (ChatRole.Tool, + [ + new FunctionResultContent("12345", "SayHello", "Said hello"), + new FunctionResultContent("12346", "SayHi", "Said hi"), + ]), + new(ChatRole.Assistant, "You are great."), + new(ChatRole.User, "Thanks!"), + ]; + + var response = await client.CompleteAsync(messages); + Assert.NotNull(response); + + Assert.Equal("chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", response.CompletionId); + Assert.Equal("I’m doing well, thank you! What’s on your mind today?", response.Message.Text); + Assert.Single(response.Message.Contents); + Assert.Equal(ChatRole.Assistant, response.Message.Role); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_727_894_187), response.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + + Assert.NotNull(response.Usage); + Assert.Equal(42, response.Usage.InputTokenCount); + Assert.Equal(15, response.Usage.OutputTokenCount); + Assert.Equal(57, response.Usage.TotalTokenCount); + Assert.NotNull(response.Usage.AdditionalProperties); + + Assert.NotNull(response.AdditionalProperties); + Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); + } + private static IChatClient CreateChatClient(HttpClient httpClient, string modelId) => new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) .AsChatClient(modelId); From 424e9748b0a4815fd291f8a7e6a4fbfb76418bbd Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 22 Oct 2024 09:51:54 -0700 Subject: [PATCH 053/190] Fix official build by resolving conflict when building the docs transport package --- ...Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj | 4 ++++ 1 file changed, 4 insertions(+) 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 430e8236a30..aa1d5b37fbc 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 @@ -27,6 +27,10 @@ Private="false" Include="$(SrcLibrariesDir)\*\*.*proj" Exclude="$(MSBuildProjectFullPath)" /> + + + From 0968e75afa659e14f00e19085475a529c29c7b59 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 22 Oct 2024 20:04:29 +0000 Subject: [PATCH 054/190] Merged PR 44144: Enable producing stable versions and flow dependencies from aspnetcore and ru... Enable producing stable versions and flow dependencies from aspnetcore and runtime ---- #### AI description (iteration 1) #### PR Classification Dependency update #### PR Summary This pull request updates various dependencies to their stable versions and adjusts build configurations to support stable version production. - Updated dependency versions from `9.0.0-rc.2` to `9.0.0` in `/eng/Version.Details.xml` and `/eng/Versions.props`. - Modified `NuGet.config` to update package sources and disable certain internal feeds. - Adjusted build pipeline configurations in `/azure-pipelines.yml` to remove the code coverage stage and disable source indexing. - Added `SuppressFinalPackageVersion` property in multiple `.csproj` files to prevent final package versioning during development stages. - Disabled NU1507 warning in `Directory.Build.props` for internal branches. --- Directory.Build.props | 5 + NuGet.config | 60 +++--- azure-pipelines.yml | 48 +---- eng/Version.Details.xml | 180 +++++++++--------- eng/Versions.props | 118 ++++++------ eng/pipelines/templates/BuildAndTest.yml | 18 ++ .../Directory.Build.props | 1 + ...icrosoft.Extensions.AI.Abstractions.csproj | 1 + ...soft.Extensions.AI.AzureAIInference.csproj | 1 + .../Microsoft.Extensions.AI.Ollama.csproj | 1 + .../Microsoft.Extensions.AI.OpenAI.csproj | 1 + .../Microsoft.Extensions.AI.csproj | 1 + ...Microsoft.Extensions.Caching.Hybrid.csproj | 1 + .../Directory.Build.props | 1 + .../Directory.Build.props | 1 + .../Directory.Build.props | 1 + ...al.Extensions.DotNetApiDocs.Transport.proj | 1 + 17 files changed, 212 insertions(+), 228 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 7e4dc9ad808..01b2c3c7c79 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -34,6 +34,11 @@ $(NetCoreTargetFrameworks) + + + $(NoWarn);NU1507 + + false latest diff --git a/NuGet.config b/NuGet.config index f91233ccab5..46fd4568af6 100644 --- a/NuGet.config +++ b/NuGet.config @@ -2,6 +2,22 @@ + + + + + + + + + + + + + + + + @@ -15,45 +31,21 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 211058cf56a..f674e637cea 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -143,7 +143,7 @@ extends: parameters: enableMicrobuild: true enableTelemetry: true - enableSourceIndex: true + enableSourceIndex: false runAsPublic: ${{ variables['runAsPublic'] }} # Publish build logs enablePublishBuildArtifacts: true @@ -220,51 +220,6 @@ extends: isWindows: false warnAsError: 0 - # ---------------------------------------------------------------- - # This stage performs quality gates enforcements - # ---------------------------------------------------------------- - - stage: codecoverage - displayName: CodeCoverage - dependsOn: - - build - condition: and(succeeded('build'), ne(variables['SkipQualityGates'], 'true')) - variables: - - template: /eng/common/templates-official/variables/pool-providers.yml@self - jobs: - - template: /eng/common/templates-official/jobs/jobs.yml@self - parameters: - enableMicrobuild: true - enableTelemetry: true - runAsPublic: ${{ variables['runAsPublic'] }} - workspace: - clean: all - - # ---------------------------------------------------------------- - # This stage downloads the code coverage reports from the build jobs, - # merges those and validates the combined test coverage. - # ---------------------------------------------------------------- - jobs: - - job: CodeCoverageReport - timeoutInMinutes: 180 - - pool: - name: NetCore1ESPool-Internal - image: 1es-mariner-2 - os: linux - - preSteps: - - checkout: self - clean: true - persistCredentials: true - fetchDepth: 1 - - steps: - - script: $(Build.SourcesDirectory)/build.sh --ci --restore - displayName: Init toolset - - - template: /eng/pipelines/templates/VerifyCoverageReport.yml - - # ---------------------------------------------------------------- # This stage only performs a build treating warnings as errors # to detect any kind of code style violations @@ -320,7 +275,6 @@ extends: parameters: validateDependsOn: - build - - codecoverage - correctness publishingInfraVersion: 3 enableSymbolValidation: false diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 2264967d976..343c8417b30 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,188 +1,188 @@ - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 990ebf52fc408ca45929fd176d2740675a67fab8 + 9c52987919f0223531191d4cfaa6487647bbf52c - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - c70204ae3c91d2b48fa6d9b92b62265f368421b4 + 85435709e560642610e746831682cf4f8fe77c34 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - c70204ae3c91d2b48fa6d9b92b62265f368421b4 + 85435709e560642610e746831682cf4f8fe77c34 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - c70204ae3c91d2b48fa6d9b92b62265f368421b4 + 85435709e560642610e746831682cf4f8fe77c34 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - c70204ae3c91d2b48fa6d9b92b62265f368421b4 + 85435709e560642610e746831682cf4f8fe77c34 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - c70204ae3c91d2b48fa6d9b92b62265f368421b4 + 85435709e560642610e746831682cf4f8fe77c34 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - c70204ae3c91d2b48fa6d9b92b62265f368421b4 + 85435709e560642610e746831682cf4f8fe77c34 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - c70204ae3c91d2b48fa6d9b92b62265f368421b4 + 85435709e560642610e746831682cf4f8fe77c34 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - c70204ae3c91d2b48fa6d9b92b62265f368421b4 + 85435709e560642610e746831682cf4f8fe77c34 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - c70204ae3c91d2b48fa6d9b92b62265f368421b4 + 85435709e560642610e746831682cf4f8fe77c34 diff --git a/eng/Versions.props b/eng/Versions.props index 5782b1bdd07..754a2c0fa4a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -10,8 +10,11 @@ $(MajorVersion).$(MinorVersion).0.0 - + release true @@ -27,52 +30,52 @@ --> - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 - 9.0.0-rc.2.24473.5 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 - 9.0.0-rc.2.24474.3 - 9.0.0-rc.2.24474.3 - 9.0.0-rc.2.24474.3 - 9.0.0-rc.2.24474.3 - 9.0.0-rc.2.24474.3 - 9.0.0-rc.2.24474.3 - 9.0.0-rc.2.24474.3 - 9.0.0-rc.2.24474.3 - 9.0.0-rc.2.24474.3 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 @@ -96,8 +99,8 @@ 8.0.1 8.0.0 8.0.2 - 8.0.10 - 8.0.10 + 8.0.11 + 8.0.11 8.0.0 8.0.1 8.0.1 @@ -110,17 +113,18 @@ 8.0.1 8.0.2 8.0.0 + 8.0.0 8.0.5 - 8.0.10 - 8.0.10 - 8.0.10 - 8.0.10 - 8.0.10 - 8.0.10 - 8.0.10 - 8.0.10 - 8.0.10 + 8.0.11 + 8.0.11 + 8.0.11 + 8.0.11 + 8.0.11 + 8.0.11 + 8.0.11 + 8.0.11 + 8.0.11 dev + true \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index bb1a3b63708..8f00d6b9271 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -8,6 +8,7 @@ preview + true 0 0 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 bfd0b8ea90b..3a66e7837f2 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 @@ -8,6 +8,7 @@ preview + true 0 0 diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj index 81beb0d7bed..ad3064c8a66 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj @@ -8,6 +8,7 @@ preview + true 0 0 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 87dda461c50..76930738579 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 @@ -8,6 +8,7 @@ preview + true 0 0 diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 2b91bf8d3a6..2dfd7347ea8 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -10,6 +10,7 @@ preview + true 0 0 diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj index 1c59ccc088a..ec8946d2f9d 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj @@ -13,6 +13,7 @@ true true dev + true EXTEXP0018 75 50 diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/Directory.Build.props b/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/Directory.Build.props index 0f62eaa4953..0ea108580da 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/Directory.Build.props +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/Directory.Build.props @@ -3,6 +3,7 @@ importing the root level Directory.Build.props file. This property should be kept in here, as opposed to moving it to the project itself. --> + true dev EXTEXP0015 diff --git a/src/Libraries/Microsoft.Extensions.Hosting.Testing/Directory.Build.props b/src/Libraries/Microsoft.Extensions.Hosting.Testing/Directory.Build.props index 6ad6add3254..77a9a53a9e9 100644 --- a/src/Libraries/Microsoft.Extensions.Hosting.Testing/Directory.Build.props +++ b/src/Libraries/Microsoft.Extensions.Hosting.Testing/Directory.Build.props @@ -3,6 +3,7 @@ importing the root level Directory.Build.props file. This property should be kept in here, as opposed to moving it to the project itself. --> + true dev EXTEXP0016 diff --git a/src/Libraries/Microsoft.Extensions.Options.Contextual/Directory.Build.props b/src/Libraries/Microsoft.Extensions.Options.Contextual/Directory.Build.props index 35ff2ae323e..59864c9c658 100644 --- a/src/Libraries/Microsoft.Extensions.Options.Contextual/Directory.Build.props +++ b/src/Libraries/Microsoft.Extensions.Options.Contextual/Directory.Build.props @@ -3,6 +3,7 @@ importing the root level Directory.Build.props file. This property should be kept in here, as opposed to moving it to the project itself. --> + true dev EXTEXP0017 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..b09c6644346 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 @@ -6,6 +6,7 @@ transport + true false Internal transport package to provide dotnet-api-docs with the reference assemblies and compiler generated documentation files from dotnet/extensions. From 696389c9b8dadcad7cd7a0cdbe1f8803ee1448e4 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Wed, 23 Oct 2024 17:29:31 +1100 Subject: [PATCH 055/190] Script to generate weekly digest (#5550) --- .gitignore | 1 + eng/scripts/Get-RepoDigest.ps1 | 281 ++++++++++++++++++++++++++ eng/scripts/repo-digest-template.html | 154 ++++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 eng/scripts/Get-RepoDigest.ps1 create mode 100644 eng/scripts/repo-digest-template.html diff --git a/.gitignore b/.gitignore index 677a001a33b..4e8c5f439a9 100644 --- a/.gitignore +++ b/.gitignore @@ -313,3 +313,4 @@ BenchmarkDotNet.artifacts/ /_TEST *.binlog +/eng/scripts/repo-digest.html diff --git a/eng/scripts/Get-RepoDigest.ps1 b/eng/scripts/Get-RepoDigest.ps1 new file mode 100644 index 00000000000..02cdf73e072 --- /dev/null +++ b/eng/scripts/Get-RepoDigest.ps1 @@ -0,0 +1,281 @@ +# Example: +# .\eng\scripts\Get-RepoDigest -ghToken +# + +[CmdletBinding()] +Param( + [Parameter(Mandatory = $True)] + [string] $ghToken +) + +$baseUri = "https://api.github.com/repos/dotnet/extensions"; +$baseLabelUri = "https://github.com/dotnet/extensions"; + +function Format-Avatar { + [CmdletBinding()] + Param( + $user + ) + + return " @$($user.login)"; +} + +function Format-IssueByArea { + [CmdletBinding()] + Param( + $issues, + [string] $columnHeader + ) + + if (!$issues) { + return ''; + } + + $issueLinks = @(); + $issueLinks += ''; + $issueLinks += ""; + $issueLinks += ""; + $issueLinks += ""; + $issueLinks += ""; + + $issues | Sort-Object created_at | ForEach-Object { + $issue = $_; + if ($issue.html_url.Contains('/pull/')) { + return; + } + + $staleDays = Get-IssueStaleDays -issue $issue; + $assignees = Get-IssueAssignees -issue $issue; + $author = Format-Avatar -user $issue.user; + + $issueLinks += ''; + $issueLinks += "" + $issueLinks += ""; + $issueLinks += ""; + $issueLinks += ""; + } + + $issueLinks += "
$columnHeaderOpen for (days)Assignee
#$($issue.number) $($issue.title)
by $author on $($issue.created_at.ToString("d MMM yyyy"))
$staleDays$assignees

"; + + return $issueLinks; +} + +function Format-IssueLabel { + [CmdletBinding()] + Param( + $label + ) + + $color = $label.color; + $r = [Convert]::ToInt32($color.Substring(0, 2), 16) + $g = [Convert]::ToInt32($color.Substring(2, 2), 16) + $b = [Convert]::ToInt32($color.Substring(4, 2), 16) + + return "$($label.name)"; +} + +function Format-UntriagedIssue { + [CmdletBinding()] + Param( + $issues, + [string] $columnHeader + ) + + $issueLinks = @(); + $issueLinks += ''; + $issueLinks += ""; + $issueLinks += ""; + $issueLinks += ""; + $issueLinks += ""; + $issueLinks += ""; + + $issues | Sort-Object created_at | ForEach-Object { + $issue = $_; + if ($issue.html_url.Contains('/pull/')) { + return; + } + + $issueLabels = Get-IssueLabels -issue $issue; + $staleDays = Get-IssueStaleDays -issue $issue; + $assignees = Get-IssueAssignees -issue $issue; + $author = Format-Avatar -user $issue.user; + + $issueLinks += ''; + $issueLinks += "" + $issueLinks += ""; + $issueLinks += ""; + $issueLinks += ""; + $issueLinks += ""; + } + + $issueLinks += "
$columnHeaderOpen for (days)AssigneeLabels
#$($issue.number) $($issue.title)
by $author on $($issue.created_at.ToString("d MMM yyyy"))
$staleDays$assignees$issueLabels

"; + + return $issueLinks; +} + +function Get-AreaLabels { + [CmdletBinding()] + Param( + ) + + $labels = @(); + $nextPattern = "(?<=<)([\S]*)(?=>; rel=`"next`")"; + + $headers = @{ + Authorization = "token $ghToken" + } + $url = "$baseUri/labels?page=1&per_page=100"; + Write-Verbose "Next URL: $url" + do { + $response = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ResponseHeadersVariable responseHeaders #-Verbose + $labels += $response; + + $url = $null; + + # See https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api#using-link-headers + $linkHeader = $responseHeaders["link"]; + if ($linkHeader -and ($linkHeader -match $nextPattern) -eq $true) { + $url = $Matches[0]; + Write-Verbose "Next URL: $url" + } + } while ($url) + + $areaLabels = @(); + $labels | Sort-Object created_at | ForEach-Object { + $label = $_; + if ($label.name.StartsWith('area-')) { + $areaLabels += $label; + } + } + + return $areaLabels | Sort-Object name; +} + +function Get-Issues { + [CmdletBinding()] + Param( + [string] $labels, + [bool] $noMilestone = $false + ) + + $issues = @(); + $nextPattern = "(?<=<)([\S]*)(?=>; rel=`"next`")"; + + $headers = @{ + Authorization = "token $ghToken" + } + $urlSuffix = if ($noMilestone) { '&milestone=none' } else { '' } + $url = "$baseUri/issues?page=1&per_page=100&labels=$labels&state=open$urlSuffix" + Write-Verbose "Next URL: $url" + do { + $response = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ResponseHeadersVariable responseHeaders #-Verbose + $issues += $response; + + $url = $null; + + # See https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api#using-link-headers + $linkHeader = $responseHeaders["link"]; + if ($linkHeader -and ($linkHeader -match $nextPattern) -eq $true) { + $url = $Matches[0]; + Write-Verbose "Next URL: $url" + } + } while ($url) + + return $issues; +} + +function Get-IssuePerArea { + [CmdletBinding()] + Param( + ) + + $issues = @(); + + $areaLabels = Get-AreaLabels + $areaLabels | ForEach-Object { + $areaLabel = $_.name; + + $header = "Issues for $(Format-IssueLabel -label $_)"; + $issuesPerLabel = Get-Issues -labels $areaLabel -noMilestone $true; + $issues += (Format-IssueByArea -issues $issuesPerLabel -columnHeader $header); + $issues += ''; + } + + return $issues; +} + + +function Get-IssueAssignees { + [CmdletBinding()] + Param( + $issue + ) + + $assignees = ''; + $issue.assignees | ForEach-Object { + $login = Format-Avatar -user $_; + $assignees += "
$login
"; + } + + return $assignees; +} + +function Get-IssueLabels { + [CmdletBinding()] + Param( + $issue + ) + + $issueLabels = ''; + $issue.labels | ForEach-Object { + $labelName = Format-IssueLabel -label $_ + $issueLabels += " $labelName"; + } + + return $issueLabels; +} + +function Get-IssueStaleDays { + [CmdletBinding()] + Param( + $issue + ) + + $staleDays = (New-TimeSpan -Start $issue.created_at -End $(Get-Date)).Days; + + if ($staleDays -gt 28) { + $staleDays = " $staleDays" + } + elseif ($staleDays -gt 14) { + $staleDays = "⚠️ $staleDays"; + } + + return $staleDays; +} + +Push-Location $PSScriptRoot + +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + # A list of comma separated label names. Example: bug,ui,@high + # See https://docs.github.com/rest/issues/issues + $untriagedIssues = Format-UntriagedIssue -issues (Get-Issues -labels 'untriaged') -columnHeader 'Untriaged issues' + + # A list of comma separated label names. Example: bug,ui,@high + # See https://docs.github.com/rest/issues/issues + $issuesPerArea = Get-IssuePerArea + + $template = Get-Content 'repo-digest-template.html'; + $template = $template.Replace('##ISSUES-UNTRIAGED##', $untriagedIssues); + $template = $template.Replace('##ISSUES-BY-AREA##', $issuesPerArea); + $template = $template.Replace('##DATE##', $((Get-Date).ToString("d MMM yyyy"))); + $template | Out-File 'repo-digest.html' -Encoding utf8 +} +catch { + Write-Error $_; + Exit -1; +} +finally { + Pop-Location +} diff --git a/eng/scripts/repo-digest-template.html b/eng/scripts/repo-digest-template.html new file mode 100644 index 00000000000..75ca1f5024b --- /dev/null +++ b/eng/scripts/repo-digest-template.html @@ -0,0 +1,154 @@ + + + + + dotnet/extensions weekly digest + + + + +
+ Hi. +

+ +
+ +

+
+


+
+

+ +
+

REQUIRED ACTION: Triage issues which belong to + your area path and remove untriaged label.
+ Refer to the Maintainers guide for more information. +
+

+ + ##ISSUES-UNTRIAGED## +
+ +

+
+


+
+

+ +
+

REQUIRED ACTION: Consider providing an update for each issue in your area with an expected + ETA.
+ Alternatively, consider closing issues which you don't expect to be working on at all.
+ Refer to the Maintainers guide for more information. +
+

+ + ##ISSUES-BY-AREA## +
+ + + \ No newline at end of file From 479b67e12735c189316d181a1fb813b7b9074298 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 23 Oct 2024 17:43:40 -0400 Subject: [PATCH 056/190] Improve EmbeddingGeneratorExtensions (#5551) * Improve EmbeddingGeneratorExtensions - Renames GenerateAsync extension method (not the interface method) to be GenerateEmbeddingAsync, since it produces a single TEmbedding - Adds GenerateEmbeddingVectorAsync, which returns a `ReadOnlyMemory` - Adds a GenerateAndZipEmbeddingsAsync, which creates a `List>` that pairs the inputs with the outputs. * Address PR feedback --- .../EmbeddingGeneratorExtensions.cs | 99 +++++++++++++++++-- .../EmbeddingGeneratorExtensionsTests.cs | 36 ++++++- .../EmbeddingGeneratorIntegrationTests.cs | 10 +- ...istributedCachingEmbeddingGeneratorTest.cs | 28 +++--- .../LoggingEmbeddingGeneratorTests.cs | 2 +- 5 files changed, 145 insertions(+), 30 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs index 944ce0995f8..efa804fd0eb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs @@ -2,26 +2,59 @@ // 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 Microsoft.Shared.Diagnostics; +#pragma warning disable S2302 // "nameof" should be used + namespace Microsoft.Extensions.AI; -/// Provides a collection of static methods for extending instances. +/// Provides a collection of static methods for extending instances. public static class EmbeddingGeneratorExtensions { - /// Generates an embedding from the specified . - /// The type from which embeddings will be generated. + /// Generates an embedding vector from the specified . + /// The type from which embeddings will be generated. /// The numeric type of the embedding data. /// The embedding generator. /// A value from which an embedding will be generated. /// The embedding generation options to configure the request. /// The to monitor for cancellation requests. The default is . /// The generated embedding for the specified . - public static async Task GenerateAsync( - this IEmbeddingGenerator generator, - TValue value, + /// + /// This operation is equivalent to using and returning the + /// resulting 's property. + /// + public static async Task> GenerateEmbeddingVectorAsync( + this IEmbeddingGenerator> generator, + TInput value, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var embedding = await GenerateEmbeddingAsync(generator, value, options, cancellationToken).ConfigureAwait(false); + return embedding.Vector; + } + + /// Generates an embedding from the specified . + /// The type from which embeddings will be generated. + /// The type of embedding to generate. + /// The embedding generator. + /// A value from which an embedding will be generated. + /// The embedding generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// + /// The generated embedding for the specified . + /// + /// + /// This operations is equivalent to using with a + /// collection composed of the single and then returning the first embedding element from the + /// resulting collection. + /// + public static async Task GenerateEmbeddingAsync( + this IEmbeddingGenerator generator, + TInput value, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) where TEmbedding : Embedding @@ -30,11 +63,61 @@ public static async Task GenerateAsync( _ = Throw.IfNull(value); var embeddings = await generator.GenerateAsync([value], options, cancellationToken).ConfigureAwait(false); + + if (embeddings is null) + { + throw new InvalidOperationException("Embedding generator returned a null collection of embeddings."); + } + if (embeddings.Count != 1) { - throw new InvalidOperationException("Expected exactly one embedding to be generated."); + throw new InvalidOperationException($"Expected the number of embeddings ({embeddings.Count}) to match the number of inputs (1)."); + } + + return embeddings[0] ?? throw new InvalidOperationException("Embedding generator generated a null embedding."); + } + + /// + /// Generates embeddings for each of the supplied and produces a list that pairs + /// each input value with its resulting embedding. + /// + /// The type from which embeddings will be generated. + /// The type of embedding to generate. + /// The embedding generator. + /// The collection of values for which to generate embeddings. + /// The embedding generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// An array containing tuples of the input values and the associated generated embeddings. + public static async Task<(TInput Value, TEmbedding Embedding)[]> GenerateAndZipAsync( + this IEmbeddingGenerator generator, + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + where TEmbedding : Embedding + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(values); + + IList inputs = values as IList ?? values.ToList(); + int inputsCount = inputs.Count; + + if (inputsCount == 0) + { + return Array.Empty<(TInput, TEmbedding)>(); + } + + var embeddings = await generator.GenerateAsync(values, options, cancellationToken).ConfigureAwait(false); + if (embeddings.Count != inputsCount) + { + throw new InvalidOperationException($"Expected the number of embeddings ({embeddings.Count}) to match the number of inputs ({inputsCount})."); + } + + var results = new (TInput, TEmbedding)[embeddings.Count]; + for (int i = 0; i < results.Length; i++) + { + results[i] = (inputs[i], embeddings[i]); } - return embeddings[0]; + return results; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs index c2e36c9c759..b6deb1ccd0f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Linq; using System.Threading.Tasks; using Xunit; @@ -12,7 +13,9 @@ public class EmbeddingGeneratorExtensionsTests [Fact] public async Task GenerateAsync_InvalidArgs_ThrowsAsync() { - await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateAsync("hello")); + await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateEmbeddingAsync("hello")); + await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateEmbeddingVectorAsync("hello")); + await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateAndZipAsync(["hello"])); } [Fact] @@ -26,6 +29,35 @@ public async Task GenerateAsync_ReturnsSingleEmbeddingAsync() Task.FromResult>>([result]) }; - Assert.Same(result, await service.GenerateAsync("hello")); + Assert.Same(result, await service.GenerateEmbeddingAsync("hello")); + Assert.Equal(result.Vector, await service.GenerateEmbeddingVectorAsync("hello")); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(10)] + public async Task GenerateAndZipEmbeddingsAsync_ReturnsExpectedList(int count) + { + string[] inputs = Enumerable.Range(0, count).Select(i => $"hello {i}").ToArray(); + Embedding[] embeddings = Enumerable + .Range(0, count) + .Select(i => new Embedding(Enumerable.Range(i, 4).Select(i => (float)i).ToArray())) + .ToArray(); + + using TestEmbeddingGenerator service = new() + { + GenerateAsyncCallback = (values, options, cancellationToken) => + Task.FromResult>>(new(embeddings)) + }; + + var results = await service.GenerateAndZipAsync(inputs); + Assert.NotNull(results); + Assert.Equal(count, results.Length); + for (int i = 0; i < count; i++) + { + Assert.Equal(inputs[i], results[i].Value); + Assert.Same(embeddings[i], results[i].Embedding); + } } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs index 806cf63e017..70eb6a31283 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs @@ -87,10 +87,10 @@ public virtual async Task Caching_SameOutputsForSameInput() .Use(CreateEmbeddingGenerator()!); string input = "Red, White, and Blue"; - var embedding1 = await generator.GenerateAsync(input); - var embedding2 = await generator.GenerateAsync(input); - var embedding3 = await generator.GenerateAsync(input + "... and Green"); - var embedding4 = await generator.GenerateAsync(input); + var embedding1 = await generator.GenerateEmbeddingAsync(input); + var embedding2 = await generator.GenerateEmbeddingAsync(input); + var embedding3 = await generator.GenerateEmbeddingAsync(input + "... and Green"); + var embedding4 = await generator.GenerateEmbeddingAsync(input); var callCounter = generator.GetService(); Assert.NotNull(callCounter); @@ -114,7 +114,7 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() .UseOpenTelemetry(sourceName: sourceName) .Use(CreateEmbeddingGenerator()!); - _ = await embeddingGenerator.GenerateAsync("Hello, world!"); + _ = await embeddingGenerator.GenerateEmbeddingAsync("Hello, world!"); Assert.Single(activities); var activity = activities.Single(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs index 9a5086a146d..a2818c7c3ed 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs @@ -43,12 +43,12 @@ public async Task CachesSuccessResultsAsync() }; // Make the initial request and do a quick sanity check - var result1 = await outer.GenerateAsync("abc"); + var result1 = await outer.GenerateEmbeddingAsync("abc"); AssertEmbeddingsEqual(_expectedEmbedding, result1); Assert.Equal(1, innerCallCount); // Act - var result2 = await outer.GenerateAsync("abc"); + var result2 = await outer.GenerateEmbeddingAsync("abc"); // Assert Assert.Equal(1, innerCallCount); @@ -134,8 +134,8 @@ public async Task AllowsConcurrentCallsAsync() }; // Act 1: Concurrent calls before resolution are passed into the inner client - var result1 = outer.GenerateAsync("abc"); - var result2 = outer.GenerateAsync("abc"); + var result1 = outer.GenerateEmbeddingAsync("abc"); + var result2 = outer.GenerateEmbeddingAsync("abc"); // Assert 1 Assert.Equal(2, innerCallCount); @@ -146,7 +146,7 @@ public async Task AllowsConcurrentCallsAsync() AssertEmbeddingsEqual(_expectedEmbedding, await result2); // Act 2: Subsequent calls after completion are resolved from the cache - var result3 = await outer.GenerateAsync("abc"); + var result3 = await outer.GenerateEmbeddingAsync("abc"); Assert.Equal(2, innerCallCount); AssertEmbeddingsEqual(_expectedEmbedding, await result1); } @@ -169,12 +169,12 @@ public async Task DoesNotCacheExceptionResultsAsync() JsonSerializerOptions = TestJsonSerializerContext.Default.Options, }; - var ex1 = await Assert.ThrowsAsync(() => outer.GenerateAsync("abc")); + var ex1 = await Assert.ThrowsAsync(() => outer.GenerateEmbeddingAsync("abc")); Assert.Equal("some failure", ex1.Message); Assert.Equal(1, innerCallCount); // Act - var ex2 = await Assert.ThrowsAsync(() => outer.GenerateAsync("abc")); + var ex2 = await Assert.ThrowsAsync(() => outer.GenerateEmbeddingAsync("abc")); // Assert Assert.NotSame(ex1, ex2); @@ -207,7 +207,7 @@ public async Task DoesNotCacheCanceledResultsAsync() }; // First call gets cancelled - var result1 = outer.GenerateAsync("abc"); + var result1 = outer.GenerateEmbeddingAsync("abc"); Assert.False(result1.IsCompleted); Assert.Equal(1, innerCallCount); resolutionTcs.SetCanceled(); @@ -215,7 +215,7 @@ public async Task DoesNotCacheCanceledResultsAsync() Assert.True(result1.IsCanceled); // Act/Assert: Second call can succeed - var result2 = await outer.GenerateAsync("abc"); + var result2 = await outer.GenerateEmbeddingAsync("abc"); Assert.Equal(2, innerCallCount); AssertEmbeddingsEqual(_expectedEmbedding, result2); } @@ -241,11 +241,11 @@ public async Task CacheKeyDoesNotVaryByEmbeddingOptionsAsync() }; // Act: Call with two different options - var result1 = await outer.GenerateAsync("abc", new EmbeddingGenerationOptions + var result1 = await outer.GenerateEmbeddingAsync("abc", new EmbeddingGenerationOptions { AdditionalProperties = new() { ["someKey"] = "value 1" } }); - var result2 = await outer.GenerateAsync("abc", new EmbeddingGenerationOptions + var result2 = await outer.GenerateEmbeddingAsync("abc", new EmbeddingGenerationOptions { AdditionalProperties = new() { ["someKey"] = "value 2" } }); @@ -277,11 +277,11 @@ public async Task SubclassCanOverrideCacheKeyToVaryByOptionsAsync() }; // Act: Call with two different options - var result1 = await outer.GenerateAsync("abc", new EmbeddingGenerationOptions + var result1 = await outer.GenerateEmbeddingAsync("abc", new EmbeddingGenerationOptions { AdditionalProperties = new() { ["someKey"] = "value 1" } }); - var result2 = await outer.GenerateAsync("abc", new EmbeddingGenerationOptions + var result2 = await outer.GenerateEmbeddingAsync("abc", new EmbeddingGenerationOptions { AdditionalProperties = new() { ["someKey"] = "value 2" } }); @@ -315,7 +315,7 @@ public async Task CanResolveIDistributedCacheFromDI() // Act: Make a request that should populate the cache Assert.Empty(_storage.Keys); - var result = await outer.GenerateAsync("abc"); + var result = await outer.GenerateEmbeddingAsync("abc"); // Assert Assert.NotNull(result); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs index e231e8995fe..5cd6267eb74 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs @@ -43,7 +43,7 @@ public async Task CompleteAsync_LogsStartAndCompletion(LogLevel level) .UseLogging() .Use(innerGenerator); - await generator.GenerateAsync("Blue whale"); + await generator.GenerateEmbeddingAsync("Blue whale"); if (level is LogLevel.Trace) { From f9ce7f877d43434ea053fcea54b5a8554617ca20 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Thu, 24 Oct 2024 11:47:42 +1100 Subject: [PATCH 057/190] Update .gitattributes (#5565) --- .gitattributes | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitattributes b/.gitattributes index 026fcf99b26..8e443edcd01 100644 --- a/.gitattributes +++ b/.gitattributes @@ -58,6 +58,3 @@ *.dbproj text=auto *.sln text=auto -*.png filter=lfs diff=lfs merge=lfs -text -*.jpg filter=lfs diff=lfs merge=lfs -text -*.dll filter=lfs diff=lfs merge=lfs -text From 8352f827227a6225849fa422400f6840de3add58 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 24 Oct 2024 08:00:14 -0400 Subject: [PATCH 058/190] Fix cloning of ChatOptions.TopK (#5564) --- .../ChatCompletion/ChatOptions.cs | 1 + .../ChatCompletion/ChatOptionsTests.cs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index b3b60c62bad..4edbed900b4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -71,6 +71,7 @@ public virtual ChatOptions Clone() Temperature = Temperature, MaxOutputTokens = MaxOutputTokens, TopP = TopP, + TopK = TopK, FrequencyPenalty = FrequencyPenalty, PresencePenalty = PresencePenalty, ResponseFormat = ResponseFormat, diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs index 2e769ff6d7e..f83169712c3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs @@ -16,6 +16,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(options.Temperature); Assert.Null(options.MaxOutputTokens); Assert.Null(options.TopP); + Assert.Null(options.TopK); Assert.Null(options.FrequencyPenalty); Assert.Null(options.PresencePenalty); Assert.Null(options.ResponseFormat); @@ -29,6 +30,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(clone.Temperature); Assert.Null(clone.MaxOutputTokens); Assert.Null(clone.TopP); + Assert.Null(clone.TopK); Assert.Null(clone.FrequencyPenalty); Assert.Null(clone.PresencePenalty); Assert.Null(clone.ResponseFormat); @@ -64,6 +66,7 @@ public void Properties_Roundtrip() options.Temperature = 0.1f; options.MaxOutputTokens = 2; options.TopP = 0.3f; + options.TopK = 42; options.FrequencyPenalty = 0.4f; options.PresencePenalty = 0.5f; options.ResponseFormat = ChatResponseFormat.Json; @@ -76,6 +79,7 @@ public void Properties_Roundtrip() Assert.Equal(0.1f, options.Temperature); Assert.Equal(2, options.MaxOutputTokens); Assert.Equal(0.3f, options.TopP); + Assert.Equal(42, options.TopK); Assert.Equal(0.4f, options.FrequencyPenalty); Assert.Equal(0.5f, options.PresencePenalty); Assert.Same(ChatResponseFormat.Json, options.ResponseFormat); @@ -89,6 +93,7 @@ public void Properties_Roundtrip() Assert.Equal(0.1f, clone.Temperature); Assert.Equal(2, clone.MaxOutputTokens); Assert.Equal(0.3f, clone.TopP); + Assert.Equal(42, clone.TopK); Assert.Equal(0.4f, clone.FrequencyPenalty); Assert.Equal(0.5f, clone.PresencePenalty); Assert.Same(ChatResponseFormat.Json, clone.ResponseFormat); @@ -118,6 +123,7 @@ public void JsonSerialization_Roundtrips() options.Temperature = 0.1f; options.MaxOutputTokens = 2; options.TopP = 0.3f; + options.TopK = 42; options.FrequencyPenalty = 0.4f; options.PresencePenalty = 0.5f; options.ResponseFormat = ChatResponseFormat.Json; @@ -139,6 +145,7 @@ public void JsonSerialization_Roundtrips() Assert.Equal(0.1f, deserialized.Temperature); Assert.Equal(2, deserialized.MaxOutputTokens); Assert.Equal(0.3f, deserialized.TopP); + Assert.Equal(42, deserialized.TopK); Assert.Equal(0.4f, deserialized.FrequencyPenalty); Assert.Equal(0.5f, deserialized.PresencePenalty); Assert.Equal(ChatResponseFormat.Json, deserialized.ResponseFormat); From 2616bb8eb96f067a025de74d75fd36b9ea8f1687 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 24 Oct 2024 08:00:28 -0400 Subject: [PATCH 059/190] Add string constructor to OllamaEmbeddingGenerator (#5562) We'd previously added one to OllamaChatClient but neglected to add one here. --- .../OllamaEmbeddingGenerator.cs | 15 ++++++++++++++- .../OllamaEmbeddingGeneratorTests.cs | 8 ++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs index 6a34a2ff811..5779b60cbc0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs @@ -21,6 +21,18 @@ public sealed class OllamaEmbeddingGenerator : IEmbeddingGeneratorThe 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 may 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. /// @@ -59,7 +71,8 @@ public void Dispose() } /// - public async Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + public async Task>> GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(values); diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs index 205398c9a1c..541aab244fe 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs @@ -17,14 +17,14 @@ public class OllamaEmbeddingGeneratorTests [Fact] public void Ctor_InvalidArgs_Throws() { - Assert.Throws("endpoint", () => new OllamaEmbeddingGenerator(null!)); - Assert.Throws("modelId", () => new OllamaEmbeddingGenerator(new("http://localhost"), " ")); + 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(new("http://localhost")); + using OllamaEmbeddingGenerator generator = new("http://localhost"); Assert.Same(generator, generator.GetService()); Assert.Same(generator, generator.GetService>>()); @@ -76,7 +76,7 @@ public async Task GetEmbeddingsAsync_ExpectedRequestResponse() using VerbatimHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using IEmbeddingGenerator> generator = new OllamaEmbeddingGenerator(new("http://localhost:11434"), "all-minilm", httpClient); + using IEmbeddingGenerator> generator = new OllamaEmbeddingGenerator("http://localhost:11434", "all-minilm", httpClient); var response = await generator.GenerateAsync([ "hello, world!", From a5e5e8c4891a94233c75ddb79510f9036de411ee Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 24 Oct 2024 08:00:42 -0400 Subject: [PATCH 060/190] Remove some defunct lazy init from chat clients (#5561) --- .../AzureAIInferenceChatClient.cs | 6 +++--- .../OpenAIChatClient.cs | 13 +++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 125449689c4..263830b5ba3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -480,15 +480,15 @@ private static List GetContentParts(IList con switch (content) { case TextContent textContent: - (parts ??= []).Add(new ChatMessageTextContentItem(textContent.Text)); + parts.Add(new ChatMessageTextContentItem(textContent.Text)); break; case ImageContent imageContent when imageContent.Data is { IsEmpty: false } data: - (parts ??= []).Add(new ChatMessageImageContentItem(BinaryData.FromBytes(data), imageContent.MediaType)); + parts.Add(new ChatMessageImageContentItem(BinaryData.FromBytes(data), imageContent.MediaType)); break; case ImageContent imageContent when imageContent.Uri is string uri: - (parts ??= []).Add(new ChatMessageImageContentItem(new Uri(uri))); + parts.Add(new ChatMessageImageContentItem(new Uri(uri))); break; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 6bcf83a7616..ab035aa327b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -530,8 +530,6 @@ private sealed class OpenAIChatToolJson { AIContent? aiContent = null; - AdditionalPropertiesDictionary? additionalProperties = null; - if (contentPart.Kind == ChatMessageContentPartKind.Text) { aiContent = new TextContent(contentPart.Text); @@ -546,7 +544,7 @@ private sealed class OpenAIChatToolJson if (imageContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) { - (additionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; + (imageContent.AdditionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; } } @@ -554,10 +552,9 @@ private sealed class OpenAIChatToolJson { if (contentPart.Refusal is string refusal) { - (additionalProperties ??= [])[nameof(contentPart.Refusal)] = refusal; + (aiContent.AdditionalProperties ??= [])[nameof(contentPart.Refusal)] = refusal; } - aiContent.AdditionalProperties = additionalProperties; aiContent.RawRepresentation = contentPart; } @@ -641,15 +638,15 @@ private static List GetContentParts(IList con switch (content) { case TextContent textContent: - (parts ??= []).Add(ChatMessageContentPart.CreateTextPart(textContent.Text)); + parts.Add(ChatMessageContentPart.CreateTextPart(textContent.Text)); break; case ImageContent imageContent when imageContent.Data is { IsEmpty: false } data: - (parts ??= []).Add(ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(data), imageContent.MediaType)); + parts.Add(ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(data), imageContent.MediaType)); break; case ImageContent imageContent when imageContent.Uri is string uri: - (parts ??= []).Add(ChatMessageContentPart.CreateImagePart(new Uri(uri))); + parts.Add(ChatMessageContentPart.CreateImagePart(new Uri(uri))); break; } } From cb16d5d6e6be455b92d2b9c996e09d4ef9f0664f Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 24 Oct 2024 08:01:01 -0400 Subject: [PATCH 061/190] Add EmbeddingGeneratorOptions.Dimensions (#5563) --- .../Embeddings/EmbeddingGenerationOptions.cs | 20 +++++++++++++++++++ .../OpenAIEmbeddingGenerator.cs | 8 +------- .../EmbeddingGenerationOptionsTests.cs | 16 +++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs index 02875e9de98..27b84273b5b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs @@ -1,11 +1,30 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Shared.Diagnostics; + namespace Microsoft.Extensions.AI; /// Represents the options for an embedding generation request. public class EmbeddingGenerationOptions { + private int? _dimensions; + + /// Gets or sets the number of dimensions requested in the embedding. + public int? Dimensions + { + get => _dimensions; + set + { + if (value is not null) + { + _ = Throw.IfLessThan(value.Value, 1); + } + + _dimensions = value; + } + } + /// Gets or sets the model ID for the embedding generation request. public string? ModelId { get; set; } @@ -22,6 +41,7 @@ public virtual EmbeddingGenerationOptions Clone() => new() { ModelId = ModelId, + Dimensions = Dimensions, AdditionalProperties = AdditionalProperties?.Clone(), }; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index 27bf001b3ff..155e047279f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -135,17 +135,11 @@ void IDisposable.Dispose() { OpenAI.Embeddings.EmbeddingGenerationOptions openAIOptions = new() { - Dimensions = _dimensions, + Dimensions = options?.Dimensions ?? _dimensions, }; if (options?.AdditionalProperties is { Count: > 0 } additionalProperties) { - // Allow per-instance dimensions to be overridden by a per-call property - if (additionalProperties.TryGetValue(nameof(openAIOptions.Dimensions), out int? dimensions)) - { - openAIOptions.Dimensions = dimensions; - } - if (additionalProperties.TryGetValue(nameof(openAIOptions.EndUserId), out string? endUserId)) { openAIOptions.EndUserId = endUserId; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs index e9dd45959c7..fbc8b390abf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.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; using System.Text.Json; using Xunit; @@ -14,10 +15,20 @@ public void Constructor_Parameterless_PropsDefaulted() EmbeddingGenerationOptions options = new(); Assert.Null(options.ModelId); Assert.Null(options.AdditionalProperties); + Assert.Null(options.Dimensions); EmbeddingGenerationOptions clone = options.Clone(); Assert.Null(clone.ModelId); Assert.Null(clone.AdditionalProperties); + Assert.Null(clone.Dimensions); + } + + [Fact] + public void InvalidArgs_Throws() + { + EmbeddingGenerationOptions options = new(); + Assert.Throws(() => options.Dimensions = 0); + Assert.Throws(() => options.Dimensions = -1); } [Fact] @@ -31,13 +42,16 @@ public void Properties_Roundtrip() }; options.ModelId = "modelId"; + options.Dimensions = 1536; options.AdditionalProperties = additionalProps; Assert.Equal("modelId", options.ModelId); + Assert.Equal(1536, options.Dimensions); Assert.Same(additionalProps, options.AdditionalProperties); EmbeddingGenerationOptions clone = options.Clone(); Assert.Equal("modelId", clone.ModelId); + Assert.Equal(1536, clone.Dimensions); Assert.Equal(additionalProps, clone.AdditionalProperties); } @@ -53,6 +67,7 @@ public void JsonSerialization_Roundtrips() options.ModelId = "model"; options.AdditionalProperties = additionalProps; + options.Dimensions = 1536; string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.EmbeddingGenerationOptions); @@ -60,6 +75,7 @@ public void JsonSerialization_Roundtrips() Assert.NotNull(deserialized); Assert.Equal("model", deserialized.ModelId); + Assert.Equal(1536, deserialized.Dimensions); Assert.NotNull(deserialized.AdditionalProperties); Assert.Single(deserialized.AdditionalProperties); From 46d5e57ca7b5203860c20d075b5e365c35f7e315 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 24 Oct 2024 13:10:13 +0100 Subject: [PATCH 062/190] Structured output improvements (continuation of PR 5522) (#5560) --- .../ChatClientStructuredOutputExtensions.cs | 69 ++++++++++++--- .../ChatCompletion/ChatCompletion{T}.cs | 27 +++++- .../Utilities/AIJsonUtilities.Schema.cs | 32 ++++++- .../AIJsonUtilitiesTests.cs | 22 ++++- .../ChatClientIntegrationTests.cs | 83 ++++++++++++++++++- ...atClientStructuredOutputExtensionsTests.cs | 68 ++++++++++++++- 6 files changed, 281 insertions(+), 20 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index 8b76682f8c8..0f847dbb296 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -1,10 +1,12 @@ // 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.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -44,8 +46,7 @@ public static Task> CompleteAsync( IList chatMessages, ChatOptions? options = null, bool? useNativeJsonSchema = null, - CancellationToken cancellationToken = default) - where T : class => + CancellationToken cancellationToken = default) => CompleteAsync(chatClient, chatMessages, AIJsonUtilities.DefaultOptions, options, useNativeJsonSchema, cancellationToken); /// Sends a user chat text message to the model, requesting a response matching the type . @@ -65,8 +66,7 @@ public static Task> CompleteAsync( string chatMessage, ChatOptions? options = null, bool? useNativeJsonSchema = null, - CancellationToken cancellationToken = default) - where T : class => + CancellationToken cancellationToken = default) => CompleteAsync(chatClient, [new ChatMessage(ChatRole.User, chatMessage)], options, useNativeJsonSchema, cancellationToken); /// Sends a user chat text message to the model, requesting a response matching the type . @@ -88,8 +88,7 @@ public static Task> CompleteAsync( JsonSerializerOptions serializerOptions, ChatOptions? options = null, bool? useNativeJsonSchema = null, - CancellationToken cancellationToken = default) - where T : class => + CancellationToken cancellationToken = default) => CompleteAsync(chatClient, [new ChatMessage(ChatRole.User, chatMessage)], serializerOptions, options, useNativeJsonSchema, cancellationToken); /// Sends chat messages to the model, requesting a response matching the type . @@ -116,7 +115,6 @@ public static async Task> CompleteAsync( ChatOptions? options = null, bool? useNativeJsonSchema = null, CancellationToken cancellationToken = default) - where T : class { _ = Throw.IfNull(chatClient); _ = Throw.IfNull(chatMessages); @@ -124,12 +122,33 @@ public static async Task> CompleteAsync( serializerOptions.MakeReadOnly(); - var schemaNode = AIJsonUtilities.CreateJsonSchema( + var schemaElement = AIJsonUtilities.CreateJsonSchema( type: typeof(T), serializerOptions: serializerOptions, inferenceOptions: _inferenceOptions); - var schema = JsonSerializer.Serialize(schemaNode, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement))); + bool isWrappedInObject; + string schema; + if (SchemaRepresentsObject(schemaElement)) + { + // For object-representing schemas, we can use them as-is + isWrappedInObject = false; + schema = JsonSerializer.Serialize(schemaElement, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement))); + } + else + { + // For non-object-representing schemas, we wrap them in an object schema, because all + // the real LLM providers today require an object schema as the root. This is currently + // true even for providers that support native structured output. + isWrappedInObject = true; + schema = JsonSerializer.Serialize(new JsonObject + { + { "$schema", "https://json-schema.org/draft/2020-12/schema" }, + { "type", "object" }, + { "properties", new JsonObject { { "data", JsonElementToJsonNode(schemaElement) } } }, + { "additionalProperties", false }, + }, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonObject))); + } ChatMessage? promptAugmentation = null; options = (options ?? new()).Clone(); @@ -152,7 +171,7 @@ public static async Task> CompleteAsync( // When not using native structured output, augment the chat messages with a schema prompt #pragma warning disable SA1118 // Parameter should not span multiple lines - promptAugmentation = new ChatMessage(ChatRole.System, $$""" + promptAugmentation = new ChatMessage(ChatRole.User, $$""" Respond with a JSON value conforming to the following schema: ``` {{schema}} @@ -166,7 +185,7 @@ public static async Task> CompleteAsync( try { var result = await chatClient.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); - return new ChatCompletion(result, serializerOptions); + return new ChatCompletion(result, serializerOptions) { IsWrappedInObject = isWrappedInObject }; } finally { @@ -176,4 +195,32 @@ public static async Task> CompleteAsync( } } } + + private static bool SchemaRepresentsObject(JsonElement schemaElement) + { + if (schemaElement.ValueKind is JsonValueKind.Object) + { + foreach (var property in schemaElement.EnumerateObject()) + { + if (property.NameEquals("type"u8)) + { + return property.Value.ValueKind == JsonValueKind.String + && property.Value.ValueEquals("object"u8); + } + } + } + + return false; + } + + private static JsonNode? JsonElementToJsonNode(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.Array => JsonArray.Create(element), + JsonValueKind.Object => JsonObject.Create(element), + _ => JsonValue.Create(element) + }; + } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs index 344a01d2c22..7166f04e744 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs @@ -57,6 +57,7 @@ public T Result { FailureReason.ResultDidNotContainJson => throw new InvalidOperationException("The response did not contain text to be deserialized"), FailureReason.DeserializationProducedNull => throw new InvalidOperationException("The deserialized response is null"), + FailureReason.ResultDidNotContainDataProperty => throw new InvalidOperationException("The response did not contain the expected 'data' property"), _ => result!, }; } @@ -103,6 +104,12 @@ public bool TryGetResult([NotNullWhen(true)] out T? result) } } + /// + /// Gets or sets a value indicating whether the JSON schema has an extra object wrapper. + /// This is required for any non-JSON-object-typed values such as numbers, enum values, or arrays. + /// + internal bool IsWrappedInObject { get; set; } + private string? GetResultAsJson() { var choice = Choices.Count == 1 ? Choices[0] : null; @@ -125,8 +132,25 @@ public bool TryGetResult([NotNullWhen(true)] out T? result) return default; } + T? deserialized = default; + // If there's an exception here, we want it to propagate, since the Result property is meant to throw directly - var deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo)_serializerOptions.GetTypeInfo(typeof(T))); + + if (IsWrappedInObject) + { + if (JsonDocument.Parse(json!).RootElement.TryGetProperty("data", out var data)) + { + json = data.GetRawText(); + } + else + { + failureReason = FailureReason.ResultDidNotContainDataProperty; + return default; + } + } + + deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo)_serializerOptions.GetTypeInfo(typeof(T))); + if (deserialized is null) { failureReason = FailureReason.DeserializationProducedNull; @@ -143,5 +167,6 @@ private enum FailureReason { ResultDidNotContainJson, DeserializationProducedNull, + ResultDidNotContainDataProperty, } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs index 4ad0603d311..46fe45342f2 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs @@ -231,6 +231,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) const string DescriptionPropertyName = "description"; const string NotPropertyName = "not"; const string TypePropertyName = "type"; + const string PatternPropertyName = "pattern"; const string EnumPropertyName = "enum"; const string PropertiesPropertyName = "properties"; const string AdditionalPropertiesPropertyName = "additionalProperties"; @@ -281,7 +282,20 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) if (ctx.Path.IsEmpty) { - // We are at the root-level schema node, append parameter-specific metadata + // We are at the root-level schema node, update/append parameter-specific metadata + + // 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. + // STJ represents .NET integer types as ["string", "integer"], which will then lead to an error. + if (TypeIsArrayContainingInteger(schema)) + { + // We don't want to emit any array for "type". In this case we know it contains "integer" + // 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] = "integer"; + _ = obj.Remove(PatternPropertyName); + } if (!string.IsNullOrWhiteSpace(key.Description)) { @@ -340,6 +354,22 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) } } + private static bool TypeIsArrayContainingInteger(JsonNode schema) + { + if (schema["type"] is JsonArray typeArray) + { + foreach (var entry in typeArray) + { + if (entry?.GetValueKind() == JsonValueKind.String && entry.GetValue() == "integer") + { + return true; + } + } + } + + return false; + } + private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) { Utf8JsonReader reader = new(utf8Json); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs index 266f7ec45e9..db482d26804 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs @@ -127,10 +127,26 @@ public static void ResolveParameterJsonSchema_ReturnsExpectedValue() JsonElement resolvedSchema; resolvedSchema = AIJsonUtilities.ResolveParameterJsonSchema(param, metadata, options); Assert.True(JsonElement.DeepEquals(generatedSchema, resolvedSchema)); + } - options = new(options) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; - resolvedSchema = AIJsonUtilities.ResolveParameterJsonSchema(param, metadata, options); - Assert.False(JsonElement.DeepEquals(generatedSchema, resolvedSchema)); + [Fact] + public static void ResolveParameterJsonSchema_TreatsIntegralTypesAsInteger_EvenWithAllowReadingFromString() + { + JsonElement expected = JsonDocument.Parse(""" + { + "type": "integer" + } + """).RootElement; + + JsonSerializerOptions options = new(JsonSerializerOptions.Default) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + AIFunction func = AIFunctionFactory.Create((int a, int? b, long c, short d) => { }, serializerOptions: options); + + AIFunctionMetadata metadata = func.Metadata; + foreach (var param in metadata.Parameters) + { + JsonElement actualSchema = Assert.IsType(param.Schema); + Assert.True(JsonElement.DeepEquals(expected, actualSchema)); + } } [Description("The type")] diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 634e4a19f9e..3f5ce32fc37 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -569,7 +570,7 @@ public virtual async Task CompleteAsync_StructuredOutput() var response = await _chatClient.CompleteAsync(""" Who is described in the following sentence? - Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. + Jimbo Smith is a 35-year-old programmer from Cardiff, Wales. """); Assert.Equal("Jimbo Smith", response.Result.FullName); @@ -578,6 +579,86 @@ Who is described in the following sentence? Assert.Equal(JobType.Programmer, response.Result.Job); } + [ConditionalFact] + public virtual async Task CompleteAsync_StructuredOutputArray() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync(""" + 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. + """); + + Assert.Equal(2, response.Result.Length); + Assert.Contains(response.Result, x => x.FullName == "Jimbo Smith"); + Assert.Contains(response.Result, x => x.FullName == "Josh Simpson"); + } + + [ConditionalFact] + public virtual async Task CompleteAsync_StructuredOutputInteger() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync(""" + There were 14 abstractions for AI programming, which was too many. + To fix this we added another one. How many are there now? + """); + + Assert.Equal(15, response.Result); + } + + [ConditionalFact] + public virtual async Task CompleteAsync_StructuredOutputString() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync(""" + The software developer, Jimbo Smith, is a 35-year-old from Cardiff, Wales. + What's his full name? + """); + + Assert.Equal("Jimbo Smith", response.Result); + } + + [ConditionalFact] + public virtual async Task CompleteAsync_StructuredOutputBool_True() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync(""" + Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. + Is there at least one software developer from Cardiff? + """); + + Assert.True(response.Result); + } + + [ConditionalFact] + public virtual async Task CompleteAsync_StructuredOutputBool_False() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync(""" + Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. + Can we be sure that he is a medical doctor? + """); + + Assert.False(response.Result); + } + + [ConditionalFact] + public virtual async Task CompleteAsync_StructuredOutputEnum() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync(""" + I'm using a Macbook Pro with an M2 chip. What architecture am I using? + """); + + Assert.Equal(Architecture.Arm64, response.Result); + } + [ConditionalFact] public virtual async Task CompleteAsync_StructuredOutput_WithFunctions() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index eea22abfacb..acb6142935e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Xunit; @@ -34,12 +35,12 @@ public async Task SuccessUsage() Assert.Null(responseFormat.SchemaName); Assert.Null(responseFormat.SchemaDescription); - // The inner client receives a trailing "system" message with the schema instruction + // The inner client receives a trailing "user" message with the schema instruction Assert.Collection(messages, message => Assert.Equal("Hello", message.Text), message => { - Assert.Equal(ChatRole.System, message.Role); + Assert.Equal(ChatRole.User, message.Role); Assert.Contains("Respond with a JSON value", message.Text); Assert.Contains("https://json-schema.org/draft/2020-12/schema", message.Text); foreach (Species v in Enum.GetValues(typeof(Species))) @@ -73,6 +74,39 @@ public async Task SuccessUsage() Assert.Equal("Hello", Assert.Single(chatHistory).Text); } + [Fact] + public async Task WrapsNonObjectValuesInDataProperty() + { + var expectedResult = new { data = 123 }; + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))]); + + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => + { + var suppliedSchemaMatch = Regex.Match(messages[1].Text!, "```(.*?)```", RegexOptions.Singleline); + Assert.True(suppliedSchemaMatch.Success); + Assert.Equal(""" + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "data": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + } + }, + "additionalProperties": false + } + """, suppliedSchemaMatch.Groups[1].Value.Trim()); + return Task.FromResult(expectedCompletion); + }, + }; + + var response = await client.CompleteAsync("Hello"); + Assert.Equal(123, response.Result); + } + [Fact] public async Task FailureUsage_InvalidJson() { @@ -206,6 +240,34 @@ public async Task CanUseNativeStructuredOutputWithSanitizedTypeName() Assert.Equal("Hello", Assert.Single(chatHistory).Text); } + [Fact] + public async Task CanUseNativeStructuredOutputWithArray() + { + var expectedResult = new[] { new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger } }; + var payload = new { data = expectedResult }; + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(payload))]); + + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => Task.FromResult(expectedCompletion) + }; + + var chatHistory = new List { new(ChatRole.User, "Hello") }; + var response = await client.CompleteAsync(chatHistory, useNativeJsonSchema: true); + + // The completion contains the deserialized result and other completion properties + Assert.Single(response.Result!); + Assert.Equal("Tigger", response.Result[0].FullName); + Assert.Equal(Species.Tiger, response.Result[0].Species); + + // TryGetResult returns the same value + Assert.True(response.TryGetResult(out var tryGetResultOutput)); + Assert.Same(response.Result, tryGetResultOutput); + + // History remains unmutated + Assert.Equal("Hello", Assert.Single(chatHistory).Text); + } + [Fact] public async Task CanSpecifyCustomJsonSerializationOptions() { @@ -224,7 +286,7 @@ public async Task CanSpecifyCustomJsonSerializationOptions() message => Assert.Equal("Hello", message.Text), message => { - Assert.Equal(ChatRole.System, message.Role); + Assert.Equal(ChatRole.User, message.Role); Assert.Contains("Respond with a JSON value", message.Text); Assert.Contains("https://json-schema.org/draft/2020-12/schema", message.Text); Assert.DoesNotContain(nameof(Animal.FullName), message.Text); // The JSO uses snake_case From 4bff11ab4e23339dda0ccad55abdfe8430cfc6ba Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 24 Oct 2024 16:13:03 -0400 Subject: [PATCH 063/190] Work around fixed bug in System.Memory.Data (#5569) * Workaround fixed bug in System.Memory.Data BinaryData had a bug in its ToString that would throw an exception if _bytes was empty. That was fixed several years ago, but Azure SDK libraries are still referencing older versions of System.Memory.Data that don't have the fix. * Add pragma warning and update condition check --- .../Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index ab035aa327b..935bb88f812 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; @@ -17,6 +17,7 @@ #pragma warning disable S1135 // Track uses of "TODO" tags #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable SA1108 // Block statements should not contain embedded comments namespace Microsoft.Extensions.AI; @@ -264,9 +265,10 @@ public async IAsyncEnumerable CompleteStreamingAs existing.CallId ??= toolCallUpdate.ToolCallId; existing.Name ??= toolCallUpdate.FunctionName; - if (toolCallUpdate.FunctionArgumentsUpdate is not null) + if (toolCallUpdate.FunctionArgumentsUpdate is { } update && + !update.ToMemory().IsEmpty) // workaround for https://github.com/dotnet/runtime/issues/68262 in 6.0.0 package { - _ = (existing.Arguments ??= new()).Append(toolCallUpdate.FunctionArgumentsUpdate); + _ = (existing.Arguments ??= new()).Append(update.ToString()); } } } From 7fe79a39d7e28f46eaa51d82a3d433bff73bd3cd Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 24 Oct 2024 19:59:54 -0400 Subject: [PATCH 064/190] Update M.E.AI.AzureAIInference for its beta2 release (#5558) - Adapt to breaking changes - Temporarily work around lack of Index on streaming updates - Add streaming usage support - Add an embedding generator --- eng/packages/General.props | 2 +- .../Embeddings/Embedding.cs | 2 + .../AzureAIChatToolJson.cs | 25 +++ .../AzureAIInferenceChatClient.cs | 152 +++++++-------- .../AzureAIInferenceEmbeddingGenerator.cs | 178 ++++++++++++++++++ .../AzureAIInferenceExtensions.cs | 12 +- .../JsonContext.cs | 70 +++++++ ...reAIInferenceChatClientIntegrationTests.cs | 5 - .../AzureAIInferenceChatClientTests.cs | 15 +- ...renceEmbeddingGeneratorIntegrationTests.cs | 13 ++ ...AzureAIInferenceEmbeddingGeneratorTests.cs | 135 +++++++++++++ .../IntegrationTestHelpers.cs | 31 +-- .../ChatClientIntegrationTests.cs | 8 +- 13 files changed, 531 insertions(+), 117 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIChatToolJson.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs diff --git a/eng/packages/General.props b/eng/packages/General.props index fbefcb50550..9c54a2351ab 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -1,7 +1,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/Embedding.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/Embedding.cs index e70469eaed3..19b8feaa182 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/Embedding.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/Embedding.cs @@ -14,6 +14,8 @@ namespace Microsoft.Extensions.AI; #endif [JsonDerivedType(typeof(Embedding), typeDiscriminator: "floats")] [JsonDerivedType(typeof(Embedding), typeDiscriminator: "doubles")] +[JsonDerivedType(typeof(Embedding), typeDiscriminator: "bytes")] +[JsonDerivedType(typeof(Embedding), typeDiscriminator: "sbytes")] public class Embedding { /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIChatToolJson.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIChatToolJson.cs new file mode 100644 index 00000000000..77e675c0830 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIChatToolJson.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Used to create the JSON payload for an AzureAI chat tool description. +internal sealed class AzureAIChatToolJson +{ + /// Gets a singleton JSON data for empty parameters. Optimization for the reasonably common case of a parameterless function. + public static BinaryData ZeroFunctionParametersSchema { get; } = new("""{"type":"object","required":[],"properties":{}}"""u8.ToArray()); + + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public List Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 263830b5ba3..ecc41140b27 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -7,7 +7,6 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Azure.AI.Inference; @@ -20,8 +19,9 @@ namespace Microsoft.Extensions.AI; /// An for an Azure AI Inference . -public sealed partial class AzureAIInferenceChatClient : IChatClient +public sealed class AzureAIInferenceChatClient : IChatClient { + /// A default schema to use when a parameter lacks a pre-defined schema. private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; /// The underlying . @@ -77,43 +77,33 @@ public async Task CompleteAsync( List returnMessages = []; // Populate its content from those in the response content. - ChatFinishReason? finishReason = null; - foreach (var choice in response.Choices) + ChatMessage message = new() { - ChatMessage returnMessage = new() - { - RawRepresentation = choice, - Role = ToChatRole(choice.Message.Role), - AdditionalProperties = new() { [nameof(choice.Index)] = choice.Index }, - }; + RawRepresentation = response, + Role = ToChatRole(response.Role), + }; - finishReason ??= ToFinishReason(choice.FinishReason); + if (response.Content is string content) + { + message.Text = content; + } - if (choice.Message.ToolCalls is { Count: > 0 } toolCalls) + if (response.ToolCalls is { Count: > 0 } toolCalls) + { + foreach (var toolCall in toolCalls) { - foreach (var toolCall in toolCalls) + if (toolCall is ChatCompletionsToolCall ftc && !string.IsNullOrWhiteSpace(ftc.Name)) { - if (toolCall is ChatCompletionsFunctionToolCall ftc && !string.IsNullOrWhiteSpace(ftc.Name)) - { - FunctionCallContent callContent = ParseCallContentFromJsonString(ftc.Arguments, toolCall.Id, ftc.Name); - callContent.RawRepresentation = toolCall; + FunctionCallContent callContent = ParseCallContentFromJsonString(ftc.Arguments, toolCall.Id, ftc.Name); + callContent.RawRepresentation = toolCall; - returnMessage.Contents.Add(callContent); - } + message.Contents.Add(callContent); } } - - if (!string.IsNullOrEmpty(choice.Message.Content)) - { - returnMessage.Contents.Add(new TextContent(choice.Message.Content) - { - RawRepresentation = choice.Message - }); - } - - returnMessages.Add(returnMessage); } + returnMessages.Add(message); + UsageDetails? usage = null; if (response.Usage is CompletionsUsage completionsUsage) { @@ -128,11 +118,11 @@ public async Task CompleteAsync( // Wrap the content in a ChatCompletion to return. return new ChatCompletion(returnMessages) { - RawRepresentation = response, CompletionId = response.Id, CreatedAt = response.Created, ModelId = response.Model, - FinishReason = finishReason, + FinishReason = ToFinishReason(response.FinishReason), + RawRepresentation = response, Usage = usage, }; } @@ -143,13 +133,13 @@ public async IAsyncEnumerable CompleteStreamingAs { _ = Throw.IfNull(chatMessages); - Dictionary? functionCallInfos = null; + Dictionary? functionCallInfos = null; ChatRole? streamedRole = default; ChatFinishReason? finishReason = default; string? completionId = null; DateTimeOffset? createdAt = null; string? modelId = null; - string? authorName = null; + string lastCallId = string.Empty; // Process each update as it arrives var updates = await _chatCompletionsClient.CompleteStreamingAsync(ToAzureAIOptions(chatMessages, options), cancellationToken).ConfigureAwait(false); @@ -161,12 +151,10 @@ public async IAsyncEnumerable CompleteStreamingAs completionId ??= chatCompletionUpdate.Id; createdAt ??= chatCompletionUpdate.Created; modelId ??= chatCompletionUpdate.Model; - authorName ??= chatCompletionUpdate.AuthorName; // Create the response content object. StreamingChatCompletionUpdate completionUpdate = new() { - AuthorName = authorName, CompletionId = chatCompletionUpdate.Id, CreatedAt = chatCompletionUpdate.Created, FinishReason = finishReason, @@ -182,34 +170,52 @@ public async IAsyncEnumerable CompleteStreamingAs } // Transfer over tool call updates. - if (chatCompletionUpdate.ToolCallUpdate is StreamingFunctionToolCallUpdate toolCallUpdate) + if (chatCompletionUpdate.ToolCallUpdate is { } toolCallUpdate) { + // TODO https://github.com/Azure/azure-sdk-for-net/issues/46830: Azure.AI.Inference + // has removed the Index property from ToolCallUpdate. It's now impossible via the + // exposed APIs to correctly handle multiple parallel tool calls, as the CallId is + // often null for anything other than the first update for a given call, and Index + // isn't available to correlate which updates are for which call. This is a temporary + // workaround to at least make a single tool call work and also make work multiple + // tool calls when their updates aren't interleaved. + if (toolCallUpdate.Id is not null) + { + lastCallId = toolCallUpdate.Id; + } + functionCallInfos ??= []; - if (!functionCallInfos.TryGetValue(toolCallUpdate.ToolCallIndex, out FunctionCallInfo? existing)) + if (!functionCallInfos.TryGetValue(lastCallId, out FunctionCallInfo? existing)) { - functionCallInfos[toolCallUpdate.ToolCallIndex] = existing = new(); + functionCallInfos[lastCallId] = existing = new(); } - existing.CallId ??= toolCallUpdate.Id; - existing.Name ??= toolCallUpdate.Name; - if (toolCallUpdate.ArgumentsUpdate is not null) + existing.Name ??= toolCallUpdate.Function.Name; + if (toolCallUpdate.Function.Arguments is { } arguments) { - _ = (existing.Arguments ??= new()).Append(toolCallUpdate.ArgumentsUpdate); + _ = (existing.Arguments ??= new()).Append(arguments); } } + if (chatCompletionUpdate.Usage is { } usage) + { + completionUpdate.Contents.Add(new UsageContent(new() + { + InputTokenCount = usage.PromptTokens, + OutputTokenCount = usage.CompletionTokens, + TotalTokenCount = usage.TotalTokens, + })); + } + // Now yield the item. yield return completionUpdate; } - // TODO: Add usage as content when it's exposed by Azure.AI.Inference. - // Now that we've received all updates, combine any for function calls into a single item to yield. if (functionCallInfos is not null) { var completionUpdate = new StreamingChatCompletionUpdate { - AuthorName = authorName, CompletionId = completionId, CreatedAt = createdAt, FinishReason = finishReason, @@ -224,7 +230,7 @@ public async IAsyncEnumerable CompleteStreamingAs { FunctionCallContent callContent = ParseCallContentFromJsonString( fci.Arguments?.ToString() ?? string.Empty, - fci.CallId!, + entry.Key, fci.Name!); completionUpdate.Contents.Add(callContent); } @@ -243,7 +249,6 @@ void IDisposable.Dispose() /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. private sealed class FunctionCallInfo { - public string? CallId; public string? Name; public StringBuilder? Arguments; } @@ -292,7 +297,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, // These properties are strongly-typed on ChatOptions but not on ChatCompletionsOptions. if (options.TopK is int topK) { - result.AdditionalProperties["top_k"] = BinaryData.FromObjectAsJson(topK, JsonContext.Default.Options); + result.AdditionalProperties["top_k"] = new BinaryData(JsonSerializer.SerializeToUtf8Bytes(topK, JsonContext.Default.Int32)); } if (options.AdditionalProperties is { } props) @@ -310,7 +315,8 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, default: if (prop.Value is not null) { - result.AdditionalProperties[prop.Key] = BinaryData.FromObjectAsJson(prop.Value, ToolCallJsonSerializerOptions); + byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, JsonContext.GetTypeInfo(prop.Value.GetType(), ToolCallJsonSerializerOptions)); + result.AdditionalProperties[prop.Key] = new BinaryData(data); } break; @@ -356,7 +362,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, } /// Converts an Extensions function to an AzureAI chat tool. - private static ChatCompletionsFunctionToolDefinition ToAzureAIChatTool(AIFunction aiFunction) + private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunction) { BinaryData resultParameters = AzureAIChatToolJson.ZeroFunctionParametersSchema; @@ -381,28 +387,11 @@ private static ChatCompletionsFunctionToolDefinition ToAzureAIChatTool(AIFunctio JsonSerializer.SerializeToUtf8Bytes(tool, JsonContext.Default.AzureAIChatToolJson)); } - return new() + return new(new FunctionDefinition(aiFunction.Metadata.Name) { - Name = aiFunction.Metadata.Name, Description = aiFunction.Metadata.Description, Parameters = resultParameters, - }; - } - - /// Used to create the JSON payload for an AzureAI chat tool description. - private sealed class AzureAIChatToolJson - { - /// Gets a singleton JSON data for empty parameters. Optimization for the reasonably common case of a parameterless function. - public static BinaryData ZeroFunctionParametersSchema { get; } = new("""{"type":"object","required":[],"properties":{}}"""u8.ToArray()); - - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public List Required { get; set; } = []; - - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; + }); } /// Converts an Extensions chat message enumerable to an AzureAI chat message enumerable. @@ -426,10 +415,9 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab string? result = resultContent.Result as string; if (result is null && resultContent.Result is not null) { - JsonSerializerOptions options = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; try { - result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object))); + result = JsonSerializer.Serialize(resultContent.Result, JsonContext.GetTypeInfo(typeof(object), ToolCallJsonSerializerOptions)); } catch (NotSupportedException) { @@ -449,20 +437,17 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab { // TODO: ChatRequestAssistantMessage only enables text content currently. // Update it with other content types when it supports that. - ChatRequestAssistantMessage message = new() - { - Content = input.Text - }; + ChatRequestAssistantMessage message = new(input.Text ?? string.Empty); foreach (var content in input.Contents) { if (content is FunctionCallContent { CallId: not null } callRequest) { - JsonSerializerOptions serializerOptions = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; - message.ToolCalls.Add(new ChatCompletionsFunctionToolCall( - callRequest.CallId, - callRequest.Name, - JsonSerializer.Serialize(callRequest.Arguments, serializerOptions.GetTypeInfo(typeof(IDictionary))))); + message.ToolCalls.Add(new ChatCompletionsToolCall( + callRequest.CallId, + new FunctionCall( + callRequest.Name, + JsonSerializer.Serialize(callRequest.Arguments, JsonContext.GetTypeInfo(typeof(IDictionary), ToolCallJsonSerializerOptions))))); } } @@ -504,11 +489,4 @@ private static List GetContentParts(IList con private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(json, callId, name, argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); - - /// Source-generated JSON type information. - [JsonSerializable(typeof(AzureAIChatToolJson))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(JsonElement))] - [JsonSerializable(typeof(int))] - private sealed partial class JsonContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs new file mode 100644 index 00000000000..84198e6b2cc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs @@ -0,0 +1,178 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Buffers.Text; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Inference; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable S109 // Magic numbers should not be used + +namespace Microsoft.Extensions.AI; + +/// An for an Azure.AI.Inference . +public sealed class AzureAIInferenceEmbeddingGenerator : + IEmbeddingGenerator> +{ + /// The underlying . + private readonly EmbeddingsClient _embeddingsClient; + + /// The number of dimensions produced by the generator. + private readonly int? _dimensions; + + /// Initializes a new instance of the class. + /// The underlying client. + /// + /// The id of the model to use. This may also be overridden per request via . + /// Either this parameter or must provide a valid model id. + /// + /// The number of dimensions to generate in each embedding. + public AzureAIInferenceEmbeddingGenerator( + EmbeddingsClient embeddingsClient, string? modelId = null, int? dimensions = null) + { + _ = Throw.IfNull(embeddingsClient); + + if (modelId is not null) + { + _ = Throw.IfNullOrWhitespace(modelId); + } + + if (dimensions is < 1) + { + Throw.ArgumentOutOfRangeException(nameof(dimensions), "Value must be greater than 0."); + } + + _embeddingsClient = embeddingsClient; + _dimensions = dimensions; + + // https://github.com/Azure/azure-sdk-for-net/issues/46278 + // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages + // 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. + var providerUrl = typeof(EmbeddingsClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(embeddingsClient) as Uri; + + Metadata = new("az.ai.inference", providerUrl, modelId, dimensions); + } + + /// + public EmbeddingGeneratorMetadata Metadata { get; } + + /// + public TService? GetService(object? key = null) + where TService : class => + typeof(TService) == typeof(EmbeddingsClient) ? (TService)(object)_embeddingsClient : + this as TService; + + /// + public async Task>> GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + var azureAIOptions = ToAzureAIOptions(values, options, EmbeddingEncodingFormat.Base64); + + var embeddings = (await _embeddingsClient.EmbedAsync(azureAIOptions, cancellationToken).ConfigureAwait(false)).Value; + + GeneratedEmbeddings> result = new(embeddings.Data.Select(e => + new Embedding(ParseBase64Floats(e.Embedding)) + { + CreatedAt = DateTimeOffset.UtcNow, + ModelId = embeddings.Model ?? azureAIOptions.Model, + })); + + if (embeddings.Usage is not null) + { + result.Usage = new() + { + InputTokenCount = embeddings.Usage.PromptTokens, + TotalTokenCount = embeddings.Usage.TotalTokens + }; + } + + return result; + } + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IEmbeddingGenerator interface. + } + + private static float[] ParseBase64Floats(BinaryData binaryData) + { + ReadOnlySpan base64 = binaryData.ToMemory().Span; + + // Remove quotes around base64 string. + if (base64.Length < 2 || base64[0] != (byte)'"' || base64[base64.Length - 1] != (byte)'"') + { + ThrowInvalidData(); + } + + base64 = base64.Slice(1, base64.Length - 2); + + // Decode base64 string to bytes. + byte[] bytes = ArrayPool.Shared.Rent(Base64.GetMaxDecodedFromUtf8Length(base64.Length)); + OperationStatus status = Base64.DecodeFromUtf8(base64, bytes.AsSpan(), out int bytesConsumed, out int bytesWritten); + if (status != OperationStatus.Done || bytesWritten % sizeof(float) != 0) + { + ThrowInvalidData(); + } + + // Interpret bytes as floats + float[] vector = new float[bytesWritten / sizeof(float)]; + bytes.AsSpan(0, bytesWritten).CopyTo(MemoryMarshal.AsBytes(vector.AsSpan())); + if (!BitConverter.IsLittleEndian) + { + Span ints = MemoryMarshal.Cast(vector.AsSpan()); +#if NET + BinaryPrimitives.ReverseEndianness(ints, ints); +#else + for (int i = 0; i < ints.Length; i++) + { + ints[i] = BinaryPrimitives.ReverseEndianness(ints[i]); + } +#endif + } + + ArrayPool.Shared.Return(bytes); + return vector; + + static void ThrowInvalidData() => + throw new FormatException("The input is not a valid Base64 string of encoded floats."); + } + + /// Converts an extensions options instance to an OpenAI options instance. + private EmbeddingsOptions ToAzureAIOptions(IEnumerable inputs, EmbeddingGenerationOptions? options, EmbeddingEncodingFormat format) + { + EmbeddingsOptions result = new(inputs) + { + Dimensions = _dimensions, + Model = options?.ModelId ?? Metadata.ModelId, + EncodingFormat = format, + }; + + if (options?.AdditionalProperties is { } props) + { + foreach (var prop in props) + { + if (prop.Value is not null) + { + byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, JsonContext.GetTypeInfo(prop.Value.GetType(), null)); + result.AdditionalProperties[prop.Key] = new BinaryData(data); + } + } + } + + return result; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs index d8ba7616316..05a6c87b33b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs @@ -12,6 +12,16 @@ public static class AzureAIInferenceExtensions /// The client. /// The id of the model to use. If null, it may be provided per request via . /// An that may be used to converse via the . - public static IChatClient AsChatClient(this ChatCompletionsClient chatCompletionsClient, string? modelId = null) => + public static IChatClient AsChatClient( + this ChatCompletionsClient chatCompletionsClient, string? modelId = null) => new AzureAIInferenceChatClient(chatCompletionsClient, modelId); + + /// Gets an for use with this . + /// The client. + /// The id of the model to use. If null, it may be provided per request via . + /// The number of dimensions to generate in each embedding. + /// An that may be used to generate embeddings via the . + public static IEmbeddingGenerator> AsEmbeddingGenerator( + this EmbeddingsClient embeddingsClient, string? modelId = null, int? dimensions = null) => + new AzureAIInferenceEmbeddingGenerator(embeddingsClient, modelId, dimensions); } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs new file mode 100644 index 00000000000..5576cbf134a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.Extensions.AI; + +/// Source-generated JSON type information. +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] +[JsonSerializable(typeof(AzureAIChatToolJson))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(float[]))] +[JsonSerializable(typeof(byte[]))] +[JsonSerializable(typeof(sbyte[]))] +internal sealed partial class JsonContext : JsonSerializerContext +{ + /// Gets the singleton used as the default in JSON serialization operations. + private static readonly JsonSerializerOptions _defaultToolJsonOptions = CreateDefaultToolJsonOptions(); + + /// Gets JSON type information for the specified type. + /// + /// This first tries to get the type information from , + /// falling back to if it can't. + /// + public static JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions? firstOptions) => + firstOptions?.TryGetTypeInfo(type, out JsonTypeInfo? info) is true ? + info : + _defaultToolJsonOptions.GetTypeInfo(type); + + /// Creates the default to use for serialization-related operations. + [UnconditionalSuppressMessage("AotAnalysis", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] + private static JsonSerializerOptions CreateDefaultToolJsonOptions() + { + // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, + // and we want to be flexible in terms of what can be put into the various collections in the object model. + // Otherwise, use the source-generated options to enable Native AOT. + + if (JsonSerializer.IsReflectionEnabledByDefault) + { + // Keep in sync with the JsonSourceGenerationOptions on JsonContext below. + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + options.MakeReadOnly(); + return options; + } + + return Default.Options; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs index 29aef62fd77..a42f1bd4ddf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Threading.Tasks; -using Microsoft.TestUtilities; namespace Microsoft.Extensions.AI; @@ -12,7 +10,4 @@ public class AzureAIInferenceChatClientIntegrationTests : ChatClientIntegrationT protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetChatCompletionsClient() ?.AsChatClient(Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_CHAT_MODEL") ?? "gpt-4o-mini"); - - public override Task CompleteStreamingAsync_UsageDataAvailable() => - throw new SkipTestException("Azure.AI.Inference library doesn't currently surface streaming usage data."); } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 9a860014b8f..4fb5122cc93 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -203,7 +203,7 @@ public async Task BasicRequestResponse_Streaming() Assert.Equal(createdAt, updates[i].CreatedAt); Assert.Equal("gpt-4o-mini-2024-07-18", updates[i].ModelId); Assert.Equal(ChatRole.Assistant, updates[i].Role); - Assert.Equal(i < 10 ? 1 : 0, updates[i].Contents.Count); + Assert.Equal(i is < 10 or 11 ? 1 : 0, updates[i].Contents.Count); Assert.Equal(i < 10 ? null : ChatFinishReason.Stop, updates[i].FinishReason); } } @@ -322,12 +322,13 @@ public async Task MultipleMessages_NonStreaming() } [Fact] - public async Task NullAssistantText_ContentSkipped_NonStreaming() + public async Task NullAssistantText_ContentEmpty_NonStreaming() { const string Input = """ { "messages": [ { + "content": "", "role": "assistant" }, { @@ -423,6 +424,7 @@ public async Task FunctionCallContent_NonStreaming() "model": "gpt-4o-mini", "tools": [ { + "type": "function", "function": { "name": "GetPersonAge", "description": "Gets the age of the specified person.", @@ -436,8 +438,7 @@ public async Task FunctionCallContent_NonStreaming() } } } - }, - "type": "function" + } } ], "tool_choice": "auto" @@ -534,6 +535,7 @@ public async Task FunctionCallContent_Streaming() "model": "gpt-4o-mini", "tools": [ { + "type": "function", "function": { "name": "GetPersonAge", "description": "Gets the age of the specified person.", @@ -547,8 +549,7 @@ public async Task FunctionCallContent_Streaming() } } } - }, - "type": "function" + } } ], "tool_choice": "auto" @@ -614,6 +615,6 @@ private static IChatClient CreateChatClient(HttpClient httpClient, string modelI new ChatCompletionsClient( new("http://somewhere"), new AzureKeyCredential("key"), - new ChatCompletionsClientOptions { Transport = new HttpClientTransport(httpClient) }) + new AzureAIInferenceClientOptions { Transport = new HttpClientTransport(httpClient) }) .AsChatClient(modelId); } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs new file mode 100644 index 00000000000..637c1475747 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs @@ -0,0 +1,13 @@ +// 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; + +public class AzureAIInferenceEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests +{ + protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => + IntegrationTestHelpers.GetEmbeddingsClient() + ?.AsEmbeddingGenerator(Environment.GetEnvironmentVariable("OPENAI_EMBEDDING_MODEL") ?? "text-embedding-3-small"); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs new file mode 100644 index 00000000000..abd5f609ed2 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Azure; +using Azure.AI.Inference; +using Azure.Core.Pipeline; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Xunit; + +#pragma warning disable S103 // Lines should not be too long + +namespace Microsoft.Extensions.AI; + +public class AzureAIInferenceEmbeddingGeneratorTests +{ + [Fact] + public void Ctor_InvalidArgs_Throws() + { + Assert.Throws("embeddingsClient", () => new AzureAIInferenceEmbeddingGenerator(null!)); + + EmbeddingsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); + Assert.Throws("modelId", () => new AzureAIInferenceEmbeddingGenerator(client, "")); + Assert.Throws("modelId", () => new AzureAIInferenceEmbeddingGenerator(client, " ")); + + using var _ = new AzureAIInferenceEmbeddingGenerator(client); + } + + [Fact] + public void AsEmbeddingGenerator_InvalidArgs_Throws() + { + Assert.Throws("embeddingsClient", () => ((EmbeddingsClient)null!).AsEmbeddingGenerator()); + + EmbeddingsClient client = new(new("http://somewhere"), new AzureKeyCredential("key")); + Assert.Throws("modelId", () => client.AsEmbeddingGenerator(" ")); + + client.AsEmbeddingGenerator(null); + } + + [Fact] + public void AsEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "amazingModel"; + + EmbeddingsClient client = new(endpoint, new AzureKeyCredential("key")); + + IEmbeddingGenerator> embeddingGenerator = client.AsEmbeddingGenerator(model); + Assert.Equal("az.ai.inference", embeddingGenerator.Metadata.ProviderName); + Assert.Equal(endpoint, embeddingGenerator.Metadata.ProviderUri); + Assert.Equal(model, embeddingGenerator.Metadata.ModelId); + } + + [Fact] + public void GetService_SuccessfullyReturnsUnderlyingClient() + { + var client = new EmbeddingsClient(new("http://somewhere"), new AzureKeyCredential("key")); + var embeddingGenerator = client.AsEmbeddingGenerator("model"); + + Assert.Same(embeddingGenerator, embeddingGenerator.GetService>>()); + Assert.Same(client, embeddingGenerator.GetService()); + + using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Use(embeddingGenerator); + + Assert.NotNull(pipeline.GetService>>()); + Assert.NotNull(pipeline.GetService>>()); + Assert.NotNull(pipeline.GetService>>()); + + Assert.Same(client, pipeline.GetService()); + Assert.IsType>>(pipeline.GetService>>()); + } + + [Fact] + public async Task GenerateAsync_ExpectedRequestResponse() + { + const string Input = """ + {"input":["hello, world!","red, white, blue"],"encoding_format":"base64","model":"text-embedding-3-small"} + """; + + const string Output = """ + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": "qjH+vMcj07wP1+U7kbwjOv4cwLyL3iy9DkgpvCkBQD0bthW98o6SvMMwmTrQRQa9r7b1uy4tuLzssJs7jZspPe0JG70KJy89ae4fPNLUwjytoHk9BX/1OlXCfTzc07M8JAMIPU7cibsUJiC8pTNGPWUbJztfwW69oNwOPQIQ+rwm60M7oAfOvDMAsTxb+fM77WIaPIverDqcu5S84f+rvFyr8rxqoB686/4cPVnj9ztLHw29mJqaPAhH8Lz/db86qga/PGhnYD1WST28YgWru1AdRTz/db899PIPPBzBE720ie47ujymPbh/Kb0scLs8V1Q7PGIFqzwVMR48xp+UOhNGYTxfwW67CaDvvOeEI7tgc228uQNoPXrLBztd2TI9HRqTvLuVJbytoPm8YVMsOvi6irzweJY7/WpBvI5NKL040ym95ccmPAfj8rxJCZG9bsGYvJkpVzszp7G8wOxcu6/ZN7xXrTo7Q90YvGTtZjz/SgA8RWxVPL/hXjynl8O8ZzGjvHK0Uj0dRVI954QjvaqKfTxmUeS8Abf6O0RhV7tr+R098rnRPAju8DtoiiK95SCmvGV0pjwQMOW9wJPdPPutxDxYivi8NLKvPI3pKj3UDYE9Fg5cvQsyrTz+HEC9uuMmPMEaHbzJ4E8778YXvVDERb2cFBS9tsIsPLU7bT3+R/+8b55WPLhRaTzsgls9Nb2tuhNG4btlzSW9Y7cpvO1iGr0lh0a8u8BkvadJQj24f6k9J51CvbAPdbwCEHq8CicvvIKROr0ESbg7GMvYPE6OCLxS2sG7/WrBPOzbWj3uP1i9TVXKPPJg0rtp7h87TSqLPCmowLxrfdy8XbbwPG06WT33jEo9uxlkvcQN17tAmVy8h72yPEdMFLz4Ewo7BPs2va35eLynScI8WpV2PENW2bwQBSa9lSufu32+wTwl4MU8vohfvRyT07ylCIe8dHHPPPg+ST0Ooag8EsIiO9F7w7ylM0Y7dfgOPADaPLwX7hq7iG8xPDW9Lb1Q8oU98twTPYDUvTomwIQ8akcfvUhXkj3mK6Q8syXxvAMb+DwfMI87bsGYPGUbJ71GHtS8XbbwvFQ+P70f14+7Uq+CPSXgxbvHfFK9icgwPQsEbbwm60O9EpRiPDjTKb3uFJm7p/BCPazDuzxh+iy8Xj2wvBqrl71a7nU9guq5PYNDOb1X2Pk8raD5u+bSpLsMD2u7C9ktPVS6gDzyjhI9vl2gPNO0AT0/vJ68XQTyvMMCWbubYhU9rzK3vLhRaToSlOK6qYIAvQAovrsa1la8CEdwPKOkCT1jEKm8Y7epvOv+HLsoJII704ZBPXbVTDubjVQ8aRnfOvspBr2imYs8MDi2vPFVVDxSrwK9hac2PYverLyxGnO9nqNQvfVLD71UEP+8tDDvurN+8Lzkbqc6tsKsu5WvXTtDKxo72b03PdDshryvXfY81JE/vLYbLL2Fp7Y7JbUGPEQ2GLyagla7fAxDPaVhhrxu7Ne7wzAZPOxXHDx5nUe9s35wPHcOizx1fM26FTGePAsEbbzzQBE9zCQMPW6TWDygucy8zPZLPM2oSjzfmy48EF4lvUttDj3NL4q8WIp4PRoEFzxKFA89uKpou9H3BDvK6009a33cPLq15rzv8VY9AQX8O1gxebzjCqo7EeJjPaA1DrxoZ2C65tIkvS0iOjxln2W8o0sKPMPXGb3Ak908cxhQvR8wDzzN1gq8DnNovMZGFbwUJiA9moJWPBl9VzkVA148TrlHO/nFCL1f7y68xe2VPIROtzvCJRu88YMUvaUzRj1qR5+7e6jFPGyrHL3/SgC9GMtYPJcT27yqMX688YOUO32+QT18iAS9cdeUPFbN+zvlx6a83d6xOzQLL7sZJNi8mSnXOuqan7uqin09CievvPw0hLyuq/c866Udu4T1t7wBXnu7zQFKvE5gyDxhUyw8qzx8vIrTLr0Kq+26TgdJPWmVoDzOiIk8aDwhPVug9Lq6iie9iSEwvOKxqjwMiyy7E59gPepMnjth+iw9ntGQOyDijbw76SW9i96sO7qKJ7ybYhU8R/6Su+GmLLzsgtu7inovPRG3pLwZUpi7YzvoucrAjjwOSKm8uuOmvLbt67wKUu68XCc0vbd0Kz0LXWy8lHmgPAAoPjxRpAS99oHMvOlBoDprUh09teLtOxoEl7z0mRA89tpLvVQQ/zyjdkk9ZZ/lvHLikrw76SW82LI5vXyIBLzVnL06NyGrPPXPzTta7nW8FTEePSVcB73FGFU9SFcSPbzL4rtXrbo84lirvcd8Urw9/yG9+63EvPdhCz2rPPw8PPQjvbXibbuo+0C8oWtLPWVG5juL3qw71Zw9PMUY1Tk3yKu8WWq3vLnYKL25A+i8zH2LvMW/1bxDr1g8Cqvtu3pPRr0FrbU8vVKiO0LSGj1b+fM7Why2ux1FUjwhv0s89lYNPUbFVLzJ4M88t/hpvdpvNj0EzfY7gC29u0HyW7yv2Tc8dSPOvNhZurzrpR28jUIqPM0vijxyDdK8iBYyvZ0fkrxalXa9JeBFPO/GF71dBHK8X8FuPKnY/jpQmQY9S5jNPGBz7TrpQaA87/FWvUHyWzwCEPq78HiWOhfuGr0ltYY9I/iJPamCgLwLBO28jZupu38ivzuIbzG8Cfnuu0dMlLypKQG7BzxyvR5QULwCEHo8k8ehPUXoFjzPvka9MDi2vPsphjwjfMi854QjvcW/VbzO4Yg7Li04vL/h3jsaL9a5iG8xuybrwzz3YYu8Gw8VvVGkBD1UugA99MRPuCjLArzvxhc8XICzPFyrcr0gDU296h7eu8jV0TxNKos8lSufuqT9CD1oDmE8sqGyu2PiaLz6osY5YjBqPBAFJrwIlfG8PlihOBE74zzzQJG8r112vJPHobyrPPw7YawrPb5doLqtzrk7qHcCPVIoQzz5l0i81UM+vFd/eryaVxc9xA3XO/6YgbweJZG7W840PF0Ecj19ZUI8x1GTOtb1vDyDnLg8yxkOvOywGz0kqgg8fTqDvKlUQL3Bnlu992ELvZPHobybCZa82LK5vf2NgzwnnUK8YMzsPKOkiTxDr9g6la/duz3/IbusR/q8lmFcvFbN+zztCRu95nklPVKBwjwEJnY6V9j5PPK50bz6okY7R6UTPPnFiDwCafk8N8grO/gTCr1iiWm8AhB6vHHXlLyV3Z08vtZgPMDsXDsck9O7mdBXvRLCojzkbqe8XxpuvDSyLzu0MO87cxhQvd3eMbxtDxo9JKqIvB8CT72zrDC7s37wPHvWhbuXQZs8UlYDu7ef6rzsV5y8IkYLvUo/Tjz+R/88PrGgujSyrzxsBJy8P7yeO7f46byfKpA8cFDVPLygIzsdGpO77LCbvLSJ7rtgzOy7sA91O0hXkrwhO408XKvyvMUYVT2mPsQ8d+DKu9lkuLy+iF89xZSWPJFjpDwIlfE8bC9bPBE7Y7z/+f08W6B0PAc8crhmquO7RvOUPDybJLwlXAe9cuKSvMPXGbxK5s48sZY0O+4UmT1/Ij+8oNyOvPIH07tNKos8yTnPO2RpKDwRO+O7vl2gvKSvB7xGmpW7nD9TPZpXFzyXQRs9InHKurhR6bwb4VS8iiwuO3pPxrxeD3A8CfluO//OPr0MaOq8r112vAwP6zynHgM9T+cHPJuNVLzLRE07EmkjvWHX6rzBGh285G4nPe6Y17sCafm8//n9PJkpVzv9P4K7IWbMPCtlvTxHKVK8JNXHO/uCBblAFZ48xyPTvGaqY7wXlRs9EDDlPHcOizyNQiq9W3W1O7iq6LxwqdQ69MRPvSJGC7n3CIy8HOxSvSjLAryU0p87QJncvEoUjzsi7Qu9U4xAOwn5brzfm668Wu71uu002rw/Y588o6SJPFfY+Tyfg4+8u5WlPMDBnTzVnD08ljadu3sBxbzfm668n4OPO9VDvrz0mZC8kFimPNiyOT134Mo8vquhvDA4Njyjz0i7zVpJu1rudbwmksQ794xKuhN0ITz/zj68Vvu7unBQ1bv8NAS97FecOyxwOzs1ZC68AIG9PKLyCryvtvU8ntEQPBkkWD2xwfO7QfLbOhqIVTykVog7lSufvKOkiTwpqEA9/RFCvKxHejx3tYu74woqPMS0VzoMtuu8ViZ7PL8PH72+L2C81JE/vN3eMTwoywK9z5OHOx4lkTwGBrW8c5QRu4khMDyvBPc8nR8SvdlkuLw0si+9S8aNvCkBwLsXwFo7Od4nPbo8pryp2P68GfkYPKpfvjrsV5w6zuEIvbHB8zxnMSM9C9mtu1nj97zjYym8XFJzPAiVcTyNm6m7X5YvPJ8qED1l+OS8WTx3vGKJ6bt+F0G9jk2oPAR0dzwIR/A8umdlvNLUwjzI1dE7yuvNvBdnW7zdhTI9xkaVPCVcB70Mtus7G7aVPDchK7xuwRi8oDWOu/SZkLxOuUe8c5QRPLBo9Dz/+f07zS+KvNBFBr1n2CO8TKNLO4ZZNbym5US5HsyRvGi1YTwxnDO71vW8PM3WCr3E4he816e7O7QFML2asBa8jZspPSVcBzvjvCi9ZGmoPHV8zbyyobK830KvOgw9q7xzZtG7R6WTPMpnjzxj4mg8mrAWPS+GN7xoZ2C8tsKsOVMIAj1fli89Zc0lO00qCzz+R/87XKvyvLxy4zy52Cg9YjBqvW9F1zybjVS8mwmWvLvA5DymugU9DOQrPJWvXbvT38C8TrnHvLbt67sgiQ49e32GPPTETzv7goW7cKnUOoOcuLpG85S8CoCuO7ef6rkaqxe90tTCPJ8qkDvuuxk8FFFfPK9ddrtAbh08roC4PAnOrztV8D08jemquwR09ziL3iy7xkaVumVG5rygNQ69CfnuPGBzbTyE9Tc9Z9ijPK8yNzxgoa084woqu1F2RLwN76m7hrI0vf7xgLwaXRY6JmeFO68ytzrrpR29XbZwPYI4uzvkFai8qHcCPRCJ5DxKFI+7dHHPPE65xzxvnta8BPs2vWaq4zwrvjy8tDDvvEq7D7076SU9q+N8PAsyLTxb+XM9xZQWPP7ufzxsXZu6BEk4vGXNJbwBXvu8xA3XO8lcEbuuJzk8GEeavGnun7sMPSs9ITsNu1yr8roj+Ik8To6IvKjQgbwIwzG8wqlZvDfIK7xln2W8B+Pyu1HPw7sBjDs9Ba01PGSU57w/Yx867FecPFdUu7w2b6w7X5avvA8l57ypKQE9oGBNPeyC27vGytM828i1PP9KAD2/4V68eZ1HvDHqtDvR94Q6UwgCPLMlcbz+w0C8HwJPu/I1k7yZ/pe8aLXhPHYDDT28oKO8p2wEvdVDvrxh+qy8WDF5vJBYpjpaR3U8vgQhPNItwrsJoG88UaQEu3e1C7yagtY6HOzSOw9+5ryYTBk9q+N8POMKqrwoywI9DLZrPCN8SDxYivi8b3MXPf/OvruvBHc8M6exvA3vKbxz7RA8Fdieu4rTrrwFVDa8Vvu7PF0Ecjs6N6e8BzzyPP/Ovrv2rww9t59qvEoUDz3HUZO7UJkGPRigmbz/+X28qjH+u3jACbxlzaW7DA9rvFLawbwLBO2547yoO1t1NTr1pI68Vs37PAI+Ojx8s8O8xnHUvPg+yTwLBO26ybUQPfUoTTw76SU8i96sPKWMRbwUqt46pj7EPGX4ZL3ILtG8AV77vM0BSjzKZ488CByxvIWnNjyIFrI83CwzPN2FsjzHUZO8rzK3O+iPIbyGCzQ98NGVuxpdlrxhrKs8hQC2vFWXvjsCaXm8oRJMPHyIBLz+HMA8W/nzvHkZCb0pqMC87m0YPCu+vDsM5Ks8VnR8vG0Pmrt0yk48y3KNvKcegzwGMXS9xZQWPDYWrTxxAtQ7IWZMPU4Hybw89CO8/eaCPPMSUTxuk9i8WAY6vGfYozsQMGW8Li24vI+mJzxKFI88HwJPPFru9btRz8O6L9+2u29F1zwC5bq7RGHXvMtyjbr5bIm7V626uxsPlTv1KE29UB3FPMwkDDupggC8SQkRvH4XQT1cJ7Q8nvzPvKsRvTu9+SI8JbUGuiP4iTx460i99JkQPNF7Qz26Dma8u+4kvHO/0LyzfvA8EIlkPUPdmLpmUWS8uxnku8f4E72ruL27BzxyvKeXwz1plSC8gpG6vEQ2mLvtYho91Zy9vLvA5DtnXGK7sZY0uyu+PLwXlZu8GquXvE2uSb0ezBG8wn6au470KD1Abh28YMzsvPQdT7xKP867Xg/wO81aSb0IarK7SY1PO5EKJTsMi6y8cH4VvcXtlbwdGhM8xTsXPQvZLbxgzOw7Pf8hPRsPlbzDMJm8ZGmoPM1aSb0HEbO8PPQjvX5wwDwQXiW9wlDaO7SJ7jxFE9a8FTEePG5omTvPkwc8vtZgux9bzrmwD3W8U2EBPAVUNj0hlIw7comTPAEF/DvKwI68YKGtPJ78Tz1boHQ9sOS1vHiSSTlVG307HsyRPHEwFDxQmQY8CaBvvB0aE70PfuY8+neHvHOUET3ssBu7+tCGPJl3WDx4wAk9d1yMPOqanzwGBjW8ZialPB7MEby1O+07J0RDu4yQq7xpGV88ZXQmPc3WCruRCqU8Xbbwu+0JG7kXGVq8SY1PvKblxDv/oH68r7Z1OynWgDklh0a8E/hfPBCJZL31/Y08sD21vA9+Zjy6DmY82WQ4PAJp+TxHTJQ8JKoIvUBunbwgDc26BzxyvVUb/bz+w8A8Wu51u8guUbyHZLM8Iu0LvJqCVj3nhKO96kwevVDyBb3UDYG79zNLO7KhMj1IgtE83NOzO0f+krw89CM9z5OHuz+OXj2TxyE8wOzcPP91v7zUZgA8DyVnvILqOTzn3aI8j/+mO8xPyzt1UQ48+R4IvQnOrzt1I067QtKau9vINb1+7AE8sA/1uy7UOLzpQSC8dqoNPSnWgDsJoO+8ANo8vfDRlbwefpC89wgMPI1CKrrYsrm78mBSvFFLBb1Pa0a8s1MxPHbVzLw+WCG9kbyjvNt6tLwfMA+8HwLPvGO3qTyyobK8DcFpPInIsLwXGdq7nBSUPGdc4ryTx6G8T+eHPBxolDvIqhK8rqv3u1fY+Tz3M0s9qNCBO/GDlL2N6Sq9XKtyPFMIgrw0Cy+7Y7epPLJzcrz/+X28la/du8MC2bwTn+C5YSXsvDneJzz/SoC8H9ePvHMY0Lx0nw+9lSsfvS3Jujz/SgC94rEqvQwP67zd3rE83NOzPKvj/DyYmpo8h2SzvF8abjye0ZC8vSRivCKfijs/vJ48NAuvvFIoQzzFGFU9dtVMPa2g+TtpGd88Uv2DO3kZiTwA2rw79f2Nu1ugdDx0nw+8di7MvIrTrjz08g+8j6anvGH6LLxQ8oW8LBc8Pf0/Ajxl+OQ8SQkRPYrTrrzyNRM8GquXu9ItQjz1Sw87C9mtuxXYnrwDl7m87Y1ZO2ChrbyhQIy4EsIiPWpHHz0inwo7teJtPJ0fEroHPPK7fp4APV/B7rwwODa8L4Y3OiaSxLsBBfw7RI8XvP5H/zxVlz68n1VPvEBuHbwTzSA8fOEDvV49sDs2b6y8mf6XPMVm1jvjvCg8ETvjPEQ2GLxK5s47Q92YuxOfYLyod4K8EDDlPHAlFj1zGFC8pWGGPE65R7wBMzy8nJjSvLoO5rwwkbU7Eu3hvLOsMDyyobI6YHNtPKs8fLzXp7s6AV57PV49MLsVMR68+4KFPIkhMLxeaG87mXdYulyAMzzQRQY9ljadu3YDDby7GWS7phOFPEJ5mzq6tea6Eu1hPJjzmTz+R388di5MvJn+F7wi7Qs8K768PFnj9zu5MSi8Gl2WvJfomzxHd1O8vw8fvONjqbxuaBk980ARPSNRiTwLMi272Fk6vDGcs7z60Ia8vX1hOzvppbuKLK48jZspvZkpV7pWJns7G7YVPdPfwLyruL08FFHfu7ZprbwT+N84+1TFPGpHn7y9JOI8xe2Vu08SR7zs29o8/RFCPCbAhDzfQi89OpCmvL194boeJZE8kQqlvES6VjrzEtE7eGeKu2kZX71rfdw8D6wmu6Y+xLzJXJE8DnPovJrbVbvkFai8KX0Bvfr7RbuXbNq8Gw+VPRCJ5LyA1D28uQPoPLygo7xENpi8/RHCvEOv2DwRtyS9o0uKPNshNbvmeSU8IyPJvCedQjy7GWQ8Wkf1vGKJ6bztYho8vHLju5cT2zzKZw+88jWTvFb7uznYCzm8" + }, + { + "object": "embedding", + "index": 1, + "embedding": "eyfbu150UDkC6hQ9ip9oPG7jWDw3AOm8DQlcvFiY5Lt3Z6W8BLPPOV0uOz3FlQk8h5AYvH6Aobv0z/E8nOQRvHI8H7rQA+s8F6X9vPplyDzuZ1u8T2cTvAUeoDt0v0Q9/xx5vOhqlT1EgXu8zfQavTK0CDxRxX08v3MIPAY29bzIpFm8bGAzvQkkazxCciu8mjyxvIK0rDx6mzC7Eqg3O8H2rTz9vo482RNiPUYRB7xaQMU80h8hu8kPqrtyPB+8dvxUvfplSD21bJY8oQ8YPZbCEDvxegw9bTJzvYNlEj0h2q+9mw5xPQ5P8TyWwpA7rmvvO2Go27xw2tO6luNqO2pEfTztTwa7KnbRvAbw37vkEU89uKAhPGfvF7u6I8c8DPGGvB1gjzxU2K48+oqDPLCo/zsskoc8PUclvXCUvjzOpQC9qxaKO1iY5LyT9XS9ZNzmvI74Lr03azk93CYTvFJVCTzd+FK8lwgmvcMzPr00q4O9k46FvEx5HbyIqO083xSJvC7PFzy/lOK7HPW+PF2ikDxeAHu9QnIrvSz59rl/UmG8ZNzmu2b4nD3V31Y5aXK9O/2+jrxljUw8y9jkPGuvTTxX5/48u44XPXFFpDwAiEm8lcuVvX6h+zwe7Lm8SUUSPHmkNTu9Eb08cP8OvYgcw7xU2C49Wm4FPeV8H72AA8c7eH/6vBI0Yj3L2GQ8/0G0PHg5ZTvHjAS9fNhAPcE8wzws2By6RWAhvWTcZjz+1uM8H1eKvHdnJT0TWR29KcVrPdu7wrvMQzW9VhW/Ozo09LvFtuM8OlmvPO5GAT3eHY68zTqwvIhiWLs1w1i9sGJqPaurOb0s2Jy8Z++XOwAU9Lggb988vnyNvVfGpLypKBS8IouVO60NBb26r/G6w+0ovbVslrz+kE68MQOjOxdf6DvoRdo8Z4RHPCvhIT3e7009P4Q1PQ0JXDyD8Ty8/ZnTuhu4Lj3X1lG9sVnlvMxDNb3wySY9cUWkPNZKJ73qyP+8rS7fPNhBojwpxes8kt0fPM7rlbwYEE68zoBFvdrExzsMzEu9BflkvF0uu7zNFfW8UyfJPPSJ3LrEBf68+6JYvef/xDpAe7C8f5h2vPqKA7xUTAS9eDllPVK8eL0+GeW7654gPQuGNr3/+x69YajbPAehRTyc5BE8pfQIPMGwGL2QoA87iGJYPYXoN7s4sc69f1JhPdYEkjxgkIa6uxpCvHtMljtYvR88uCzMPBeEo7wm1/U8GBDOvBkHybwyG3i7aeaSvQzMyzy3e2a9xZUJvVSSmTu7SII8x4yEPKAYHTxUTIQ8lcsVO5x5QT3VDRe963llO4K0rLqI1i07DX0xvQv6CznrniA9nL9WPTvl2Tw6WS+8NcPYvEL+VbzZfrK9NDcuO4wBNL0jXVW980PHvNZKJz1Oti09StG8vIZTiDwu8PE8zP0fO9340juv1j890vFgvMFqAz2kHui7PNxUPQehxTzjGlQ9vcunPL+U4jyfrUw8R+NGPHQF2jtSdmO8mYtLvF50ULyT1Bo9ONaJPC1kx7woznC83xQJvUdv8byEXA29keaku6Qe6Ly+fA29kKAPOxLuzLxjxJG9JnCGur58jTws2Jy8CkmmO3pVm7uwqH87Eu7Mu/SJXL0IUis9MFI9vGnmEr1Oti09Z+8XvH1DkbwcaZS8NDcuvT0BkLyPNT89Haakuza607wv5+w81KLGO80VdT3MiUq8J4hbPHHRzrwr4aG8PSJqvJOOBT3t2zC8eBgLvXchkLymOp66y9jkPDdG/jw2ulO983GHPDvl2Tt+Ooy9NwDpOzZ0Pr3xegw7bhGZvEpd57s5YjS9Gk1evIbfMjxBwcW8NnQ+PMlVPzxR6ji9M8zdPImHk7wQsby8u0gCPXtMFr22YxE9Wm4FPaXPzbygGJ093bK9OuYtBTxyXfk8iYeTvNH65byk/Q29QO+FvKbGyLxCcqs9nL/WvPtcQ72XTjs8kt2fuhaNKDxqRH08KX9WPbmXnDtXDDo96GoVPVw3QL0eeGS8ayOjvAIL7zywQZC9at0NvUMjET1Q8707eTDgvIio7Tv60Jg87kYBOw50LLx7BgE96qclPUXsSz0nQkY5aDUtvQF/RD1bZQC73fjSPHgYCzyPNT+9q315vbMvhjsvodc8tEdbPGcQ8jz8U768cYs5PIwBtL38x5M9PtPPvIex8jzfFIk9vsIivLsaQj2/uZ072y8YvSV5C7uoA9k8JA67PO5nWzvS8eC8av7nuxSWrbybpwE9f5h2vG3sXTmoA1k9sjiLvTBSPbxc8Sq9UpuePB+dHz2/cwg9BWS1vCrqJr2M3Pg86LAqPS/GEj3oRdq8GiyEvACISbuiJ+28FFAYuzBSvTzwDzy8K5uMvE5wmDpd6CW6dkJqPGlyvTwF2Iq9f1JhPSHarzwDdr88JXkLu4ADxzx5pDW7zqUAvdAoJj24wXs8doj/PH46jD2/2vc893fSuyxtTL0YnPg7IWbaPOiwqrxLDk27ZxDyPBpymbwW0z08M/odPTufRL1AVvU849Q+vBGDfD3JDyq6Z6kCPL9OzTz0rpe8FtM9vaDqXLx+W2Y7jHWJPGXT4TwJ3lW9M4bIPPCDkTwoZwE9XH1VOmksqLxLPI08cNrTvCyz4bz+Srm8kiO1vDP6nbvIpNk8MrSIvPe95zoTWR29SYsnPYC9MT2F6De93qm4PCbX9bqqhv47yky6PENE67x/DEw8JdYAvUdvcbywh6W8//ueO8fSmTyjTCi9yky6O/qr3TzvGEE8wqcTPeDmSDyuJVo8ip/ou1HqOLxOtq28y5LPuxk1Cb0Ddr+7c+2EvKQeaL1SVQk8XS47PGTcZjwdpiQ8uFqMO0QaDD1XxqS8mLmLuuSFJDz1xmy8PvgKvJAHf7yC+kE8VapuvetYC7tHCAI8oidtPOiwqjyoSW68xCo5vfzobTzz2HY88/0xPNkT4rty9om8RexLu9SiRrsVaG081gSSO5IjtTsOLpc72sTHPGCQBj0QJRI9BCclPI1sBDzCyO07QHuwvOYthTz4tGK5QHuwvWfvFz2CQNc8PviKPO8YwTuQoA89fjoMPBnBs7zGZ8m8uiPHvMdeRLx+gKE8keaku0wziDzZWfe8I4KQPJ0qpzs4sc47dyEQPEQaDDzVmcE8//uePJcIJjztTwa9ogaTOftcwztU2K48opvCuyz5drzqM1C7iYcTvfDJJjxXxiQ9o0wovO1PBrwqvGa7dSoVPbI4izvnuS88zzGrPH3POzzHXkQ9PSJqOXCUPryW4+o8ELE8PNZKp7z+Sjm8foChPPIGtzyTaUq8JA47vBiceDw3a7m6jWyEOmksKDwH59q5GMo4veALBL0SqDe7IaxvvBD3Ubxn7xc9+dkdPSBOBTxHCAI8mYvLOydCxjw5HB88zTqwvJXs77w9AZA9CxvmvIeQGL2rffm8JXkLPKqGfjyoSe464d1DPPd3UrpO/EK8qxYKvUuCojwhZlq8EPfRPKaAs7xKF9K85i0FvEYRhzyPNT88m6cBvdSiRjxnqQI9uOY2vcBFSLx4OeW7BxUbPCz59rt+W2Y7SWZsPGzUCLzE5KM7sIclvIdr3buoSW47AK0EPImHE7wgToU8IdovO7FZ5bxbzO+8uMF7PGayB7z6ioO8zzErPEcIgrxSm568FJYtvNf7jDyrffm8KaQRPcoGpTwleQu8EWKiPHPthLz44qI8pEOjvWh7QjzpPNU8lcuVPHCUPr3n/8Q8bNQIu0WmNr1Erzs95VfkPCeIW7vT0Aa7656gudH65bxw/w49ZrKHPHsn27sIUiu8mEU2vdUNF7wBf8Q809CGPFtlgDo1fcO85i2FPEcIAjwL+os653OavOu1AL2EN9K8H52fPKzoybuMdYk8T2cTO8lVPzyK5X07iNYtvD74ijzT0IY8RIF7vLLENbyZi8s8KwJ8vAne1TvGZ8k71gSSumJZwTybp4G8656gPG8IFL27SAI9arjSvKVbeDxljcy83fjSuxu4Lr2DZRK9G0TZvLFZ5bxR6ji8NPEYPbI4izyAvTE9riVaPCCUGrw0Ny48f1LhuzIb+DolBTY8UH9ou/4EpLyAvTG9CFIrvCBOBTlkIvy8WJhkvHIXZLkf47Q8GQfJvBpNXr1pcr07c8jJO2nmkrxOcJi8sy8GuzjWibu2Pta8WQO1PFPhs7z7XEO8pEMjvb9OzTz4bs08EWKiu0YyYbzeHQ695D+PPKVbeDzvGEG9B6HFO0uCojws+Xa7JQW2OpRgRbxjCqc8Sw7NPDTxmLwjXVW8sRNQvFPhszzM/Z88rVMavZPUGj06WS+8JpHgO3etursdx369uZccvKplJDws+Xa8fzGHPB1gj7yqZaQ887ecPBNZHbzoi2+7NwDpPMxDtbzfWh49H+O0PO+kaztI2kE8/xz5PImHE73fNWO8T60ovIPxPDvR2Yu8XH3VvMcYr7wfnR+9fUORPIdr3Tyn6wO9nkL8vM2uhTzGIbS66u26vE2/MrxFYKE8iwo5vLSNcLy+wiK9GTUJPK10dLzrniC8qkBpvPxTPrwzQLO8illTvFi9H7yMATS7ayOjO14Ae7z19Cy87dswPKbGyDzujJa93EdtPdsB2LYT5Ue9RhEHPKurubxm+By9+mVIvIy7HrxZj987yOpuvUdv8TvgCwS8TDMIO9xsqLsL+gs8BWS1PFRMBD1yXXm86GoVvK+QqjxRXg46TZHyu2ayhzx7TJa8uKAhPLyFkjsV3MI7niGiPGNQvDxgkIa887ccPUmLJ7yZsIa8KDnBvHgYi7yMR0m82ukCvRuK7junUvO8aeYSPXtt8LqXCKa84kgUPd5jIzxlRze93xQJPNNcMT2v1j889GiCPKRkfbxz7YQ8b06pO8cYL7xg9/U8yQ+qPGlyvbzfNWO8vZ3nPBGD/DtB5gC7yKRZPPTPcbz6q928bleuPI74rrzVDRe9CQORvMmb1Dzv0qs8DBLhu4dr3bta1fQ8aeYSvRD3UTugpMe8CxvmPP9BNDzHjAQ742DpOzXD2Dz4bk28c1T0Onxka7zEBf48uiNHvGayBz1pcj29NcPYvDnu3jz5kwg9WkBFvL58jTx/mHY8wTzDPDZ0Pru/uZ08PQGQPOFRmby4oKE8JktLPIx1iTsppBG9dyGQvHfzT7wzhki44KAzPSOCkDzv0iu8lGBFO2VHNzyKxKM72EEiPYtQzryT9fQ8UDnTPEx5nTzuZ9s8QO8FvG8IlDx7J9s6MUk4O9k4nbx7TBa7G7iuvCzYHDocr6k8/7UJPY2ymTwVIlg8KjC8OvSuFz2iJ+28cCBpvE0qAzw41ok7sgrLvPjiojyG37K6lwimvKcxGTwRHI28y5LPO/mTiDx82MC5VJIZPWkH7TwPusG8YhOsvH1DkbzUx4E8TQXIvO+ka7zKwI+8w+2oPNLxYLzxegy9zEM1PDo0dDxIINc8FdxCO46E2TwPRmw9+ooDvMmb1LwBf0S8CQMRvEXsS7zPvdU80qvLPLfvO7wbuK68iBzDO0cpXL2WndU7dXCqvOTLubytLl88LokCvZj/IDw0q4M8G7guvNkTYrq5UQe7vcunvIrEI7xuERm9RexLvAdbsDwLQCE7uVEHPYjWrbuM3Pi8g2WSO3R5L7x4XiC8vKZsu9Sixros+fa8UH/ouxxpFL3wyaa72sRHu2YZ9zuiJ2274o4pOjkcnzyagka7za4FvYrEozwCMCo7cJQ+vfqKAzzJ4em8fNhAPUB7sLylz80833v4vOU2ir1ty4M8UV4OPXQF2jyu30S9EjRivBVo7TwXX2g70ANrvEJyq7wQJRK99jE9O7c10brUxwE9SUUSPS4VLbzBsJg7FHHyPMz9n7latJo8bleuvBpN3jsF+WS8Ye7wO4nNKL0TWZ08iRM+vOn2v7sB8xm9jY3ePJ/zYbkLG+a7ZvicvGxgM73L2OS761iLPKcxmTrX+ww8J0JGu1MnyTtJZuw7pIm4PJbCED29V1K9PFCqPLBBkLxhYka8hXTiPEB7MDzrniA7h5CYvIR9ZzzARcg7TZHyu4sKOb1in9Y7nL9WO6gD2TxSduO8UaQjPQO81Lxw/w69KwL8O4FJ3D2XTju8SE6XPGDWGz0K1VC8YhMsvObCtDyndy49BCclu68cVbxemYu8sGLqOksOzTzj1L47ISBFvLly4Ttk3Oa8RhGHNwzxBj0v5+y7ogaTPA+6QbxiE6w8ubj2PDixzrstZEe9jbKZPPd30rwqMDw8TQXIPFurlTxx0c68jLsePfSJ3LuXTru8yeHpu6Ewcjx5D4a8BvBfvN8Uibs9R6W8lsIQvaEw8rvVUyw8SJQsPebCNDwu8PE8GMo4OxAlkjwJmMA8KaQRvdYlbDwNNxy9ouHXPDffDrxwZv46AK0EPJqCRrpWz6k8/0E0POAs3rxmsoe7zTqwO5mLyzyP7ym7wTzDvFB/aLx5D4a7doj/O67fxDtsO/g7uq9xvMWViTtC/tU7PhnlvIEogjxxRSQ9SJSsPIJA1zyBKAI9ockCPYC9MbxBTXC83xSJvPFVUb1n75c8uiNHOxdf6Drt27A8/FM+vJOvXz3a6QI8UaQjuvqKgzyOhNm831oevF+xYLxjCic8sn6gPDdrOTs3Rv66cP+Ou5785rycBew8J0JGPJOOBbw9Imq8q335O3MOX7xemQs8PtNPPE1L3Tx5dnU4A+EPPLrdsTzfFIm7LJIHPB4yz7zbAdi8FWjtu1h3Cj0oznA8kv55PKgDWbxIINc8xdsePa8cVbzmlHQ8IJSavAgMlrx4XiA8z3dAu2PEET3xm+a75//EvK2Zr7xbqxU8zP2fvOSFJD1xRSS7k44FvPzHkzz5+ne8+tAYvd5jIz1GMuE8yxSAO3KCNDyRuOS8wzO+vObCNDwzQLO7isQjva1TGrz6ioM79GgCPF66Zbx1KpW8qW6pu4RcDTzcJhO9SJQsO5G45LsAiMm8lRErvJqCxjzQbju7w3nTuTclpDywqP88ysCPvAF/xLxfa0u88cChPBjKODyaPLE8k69fvGFiRrvuRgG9ATmvvJEsOr21+EC9KX/WOrmXnDwDAuo8yky6PI1sBDvztxy8PviKPKInbbzbdS276mGQO2Kf1rwn/DC8ZrIHPBRxcj0z+h264d1DPdG0ULxvTqm5bDt4vToTmjuGJcg7tmMRO9YEEr3oJAC9THmdPKn607vcJhM8Zj6yvHR5r7ywYmq83fjSO5mLyzshIEU8EWKiuu9eVjw75dk7fzGHvNl+sjwJJOs8YllBPAtheztz7QQ92lDyvDEDozzEKrk7KnZRvG8pbjsdYI+7yky6OfWAVzzjYGk7NX3DOzrNhDyeIaI8joTZvFcMOryYRba8G7iuu893QDw9RyW7za6FvDUJ7rva6YK9D7rBPD1o/zxCLJa65TaKvHsGAT2g6ly8+tCYu+wqy7xeAHu8vZ1nPBv+QzwfVwo8CMYAvM+91TzKTDq8Ueo4u2uvzTsBf8Q8p+uDvKofDz12tj+8wP+yOlkDtTwYyji6ZdPhPGv14rwqdtE8YPf1vLIKy7yFLs28ouFXvO1PBj15pDU83xQJPdfWUTz8x5O64kgUPBQKA72eIaK6A3a/OyzYnLoYnPg4XMNqPdxsqLsKSaY7pfSIvBoshLupKJS8G0TZOu/SqzzFcE47cvaJPA19Mb14dQC8sVllvJmwhjycBey8cvaJOmSWUbvRtFC8WtX0O2r+57twIGm8yeFpvFuG2rzCyO08PUelPK5rbzouFS29uCxMPQAUdDqtma88wqeTu5gge7zH8/O7l067PJdOO7uKxCO8/xx5vKt9+TztTwa8OhOaO+Q/Dzw33w49CZhAvSubjDydttG8IdovPIADR7stHrI7ATmvvOAs3rzL2OQ69K4XvNccZ7zlV2S8c+0EPfNDxzydKqc6LLPhO8YhtDyJhxM9H1eKOaNMKLtOcBg9HPU+PTsrbzvT0Ia8BG26PB2mpDp7TJa8wP8yPVvM77t0ea86eTBgvFurFT1C/tW7CkkmvKOSPT2aPDG9lGDFPAhSq7u5UYc8l5TQPFh3ijz9vg68lGBFO4/vKTxViZS7eQ8GPTNAs7xmsoe8o0yoPJfaZbwlvyA8IazvO0XsS717TJY8flvmOgHFWbyWnVW8mdFgvJbCkDynDF68" + } + ], + "model": "text-embedding-3-small", + "usage": { + "prompt_tokens": 9, + "total_tokens": 9 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IEmbeddingGenerator> generator = new EmbeddingsClient(new("http://somewhere"), new AzureKeyCredential("key"), new() + { + Transport = new HttpClientTransport(httpClient), + }).AsEmbeddingGenerator("text-embedding-3-small"); + + 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("text-embedding-3-small", e.ModelId); + Assert.NotNull(e.CreatedAt); + Assert.Equal(1536, e.Vector.Length); + Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs index 4c4086e1157..e1a2076a6c7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs @@ -10,22 +10,23 @@ namespace Microsoft.Extensions.AI; /// Shared utility methods for integration tests. internal static class IntegrationTestHelpers { - /// Gets an to use for testing, or null if the associated tests should be disabled. - public static ChatCompletionsClient? GetChatCompletionsClient() - { - string? apiKey = - Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_APIKEY") ?? - Environment.GetEnvironmentVariable("OPENAI_API_KEY"); + private static readonly string? _apiKey = + Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_APIKEY") ?? + Environment.GetEnvironmentVariable("OPENAI_API_KEY"); - if (apiKey is not null) - { - string? endpoint = - Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_ENDPOINT") ?? - "https://api.openai.com/v1"; + private static readonly string _endpoint = + Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_ENDPOINT") ?? + "https://api.openai.com/v1"; - return new(new Uri(endpoint), new AzureKeyCredential(apiKey)); - } + /// Gets an to use for testing, or null if the associated tests should be disabled. + public static ChatCompletionsClient? GetChatCompletionsClient() => + _apiKey is string apiKey ? + new ChatCompletionsClient(new Uri(_endpoint), new AzureKeyCredential(apiKey)) : + null; - return null; - } + /// Gets an to use for testing, or null if the associated tests should be disabled. + public static EmbeddingsClient? GetEmbeddingsClient() => + _apiKey is string apiKey ? + new EmbeddingsClient(new Uri(_endpoint), new AzureKeyCredential(apiKey)) : + null; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 3f5ce32fc37..0863e31db37 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -110,7 +110,13 @@ public virtual async Task CompleteStreamingAsync_UsageDataAvailable() { SkipIfNotEnabled(); - var response = _chatClient.CompleteStreamingAsync("Explain in 10 words how AI works"); + var response = _chatClient.CompleteStreamingAsync("Explain in 10 words how AI works", new() + { + AdditionalProperties = new() + { + ["stream_options"] = new Dictionary { ["include_usage"] = true, }, + }, + }); List chunks = []; await foreach (var chunk in response) From 443dc6e0052405b308671b5d4ca94ae2368d1d0d Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 25 Oct 2024 14:34:58 -0700 Subject: [PATCH 065/190] Branding updates for 9.1 --- eng/Versions.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eng/Versions.props b/eng/Versions.props index 5782b1bdd07..ff12d720ed5 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -1,10 +1,10 @@ 9 - 0 + 1 0 preview - 9 + 1 $(MajorVersion).$(MinorVersion).$(PatchVersion) true $(MajorVersion).$(MinorVersion).0.0 From 5a850c70b17a10910104d5a22ce5617a94272e1e Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 25 Oct 2024 16:12:23 -0700 Subject: [PATCH 066/190] Update feeds to use latest builds from .NET --- NuGet.config | 24 +++++------ eng/Version.Details.xml | 90 ++++++++++++++++++++--------------------- 2 files changed, 55 insertions(+), 59 deletions(-) diff --git a/NuGet.config b/NuGet.config index 46fd4568af6..e7e2d21b415 100644 --- a/NuGet.config +++ b/NuGet.config @@ -5,17 +5,15 @@ - + + - - - - - - + + + @@ -34,18 +32,16 @@ - + + - - - - - - + + + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 343c8417b30..ac06fe4457c 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -2,99 +2,99 @@ https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 9c52987919f0223531191d4cfaa6487647bbf52c + 0456c7e91c34003f26acf8606ba9d20e29f518bd https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 85435709e560642610e746831682cf4f8fe77c34 + 592ca7fd80495bc6625c8b9d309355b6a8609861 https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 85435709e560642610e746831682cf4f8fe77c34 + 592ca7fd80495bc6625c8b9d309355b6a8609861 https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 85435709e560642610e746831682cf4f8fe77c34 + 592ca7fd80495bc6625c8b9d309355b6a8609861 https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 85435709e560642610e746831682cf4f8fe77c34 + 592ca7fd80495bc6625c8b9d309355b6a8609861 https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 85435709e560642610e746831682cf4f8fe77c34 + 592ca7fd80495bc6625c8b9d309355b6a8609861 https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 85435709e560642610e746831682cf4f8fe77c34 + 592ca7fd80495bc6625c8b9d309355b6a8609861 https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 85435709e560642610e746831682cf4f8fe77c34 + 592ca7fd80495bc6625c8b9d309355b6a8609861 https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 85435709e560642610e746831682cf4f8fe77c34 + 592ca7fd80495bc6625c8b9d309355b6a8609861 https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 85435709e560642610e746831682cf4f8fe77c34 + 592ca7fd80495bc6625c8b9d309355b6a8609861 From 090d7a2113330dbf093496284569078a8600b417 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 25 Oct 2024 23:10:16 -0400 Subject: [PATCH 067/190] Add NativeAOT testapp project for M.E.AI (#5573) * Add NativeAOT testapp project for M.E.AI * Address PR feedback --- .../JsonContext.cs | 4 +- .../OpenAIChatClient.cs | 56 +++++++++++++++++-- .../Utilities/AIJsonUtilities.Defaults.cs | 4 +- ...ensions.AI.AotCompatibility.TestApp.csproj | 26 +++++++++ .../Program.cs | 22 ++++++++ 5 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj create mode 100644 test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Program.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs index 5576cbf134a..1e1dabffab7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs @@ -48,11 +48,11 @@ private static JsonSerializerOptions CreateDefaultToolJsonOptions() { // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, // and we want to be flexible in terms of what can be put into the various collections in the object model. - // Otherwise, use the source-generated options to enable Native AOT. + // Otherwise, use the source-generated options to enable trimming and Native AOT. if (JsonSerializer.IsReflectionEnabledByDefault) { - // Keep in sync with the JsonSourceGenerationOptions on JsonContext below. + // Keep in sync with the JsonSourceGenerationOptions attribute on JsonContext above. JsonSerializerOptions options = new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver(), diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 935bb88f812..42851cdf62f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -3,11 +3,13 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -587,10 +589,9 @@ private sealed class OpenAIChatToolJson string? result = resultContent.Result as string; if (result is null && resultContent.Result is not null) { - JsonSerializerOptions options = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; try { - result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object))); + result = JsonSerializer.Serialize(resultContent.Result, JsonContext.GetTypeInfo(typeof(object), ToolCallJsonSerializerOptions)); } catch (NotSupportedException) { @@ -617,7 +618,9 @@ private sealed class OpenAIChatToolJson ChatToolCall.CreateFunctionToolCall( callRequest.CallId, callRequest.Name, - BinaryData.FromObjectAsJson(callRequest.Arguments, ToolCallJsonSerializerOptions))); + new(JsonSerializer.SerializeToUtf8Bytes( + callRequest.Arguments, + JsonContext.GetTypeInfo(typeof(IDictionary), ToolCallJsonSerializerOptions))))); } } @@ -670,8 +673,53 @@ private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8 argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); /// Source-generated JSON type information. + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] [JsonSerializable(typeof(OpenAIChatToolJson))] [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(JsonElement))] - private sealed partial class JsonContext : JsonSerializerContext; + private sealed partial class JsonContext : JsonSerializerContext + { + /// Gets the singleton used as the default in JSON serialization operations. + private static readonly JsonSerializerOptions _defaultToolJsonOptions = CreateDefaultToolJsonOptions(); + + /// Gets JSON type information for the specified type. + /// + /// This first tries to get the type information from , + /// falling back to if it can't. + /// + public static JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions? firstOptions) => + firstOptions?.TryGetTypeInfo(type, out JsonTypeInfo? info) is true ? + info : + _defaultToolJsonOptions.GetTypeInfo(type); + + /// Creates the default to use for serialization-related operations. + [UnconditionalSuppressMessage("AotAnalysis", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] + private static JsonSerializerOptions CreateDefaultToolJsonOptions() + { + // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, + // and we want to be flexible in terms of what can be put into the various collections in the object model. + // Otherwise, use the source-generated options to enable trimming and Native AOT. + + if (JsonSerializer.IsReflectionEnabledByDefault) + { + // Keep in sync with the JsonSourceGenerationOptions attribute on JsonContext above. + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + options.MakeReadOnly(); + return options; + } + + return Default.Options; + } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs index 94340160cb1..de2c2a695b6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs @@ -23,11 +23,11 @@ private static JsonSerializerOptions CreateDefaultOptions() { // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, // and we want to be flexible in terms of what can be put into the various collections in the object model. - // Otherwise, use the source-generated options to enable Native AOT. + // Otherwise, use the source-generated options to enable trimming and Native AOT. if (JsonSerializer.IsReflectionEnabledByDefault) { - // Keep in sync with the JsonSourceGenerationOptions on JsonContext below. + // Keep in sync with the JsonSourceGenerationOptions attribute on JsonContext below. JsonSerializerOptions options = new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver(), diff --git a/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj new file mode 100644 index 00000000000..183cd150937 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj @@ -0,0 +1,26 @@ + + + + Exe + $(LatestTargetFramework) + true + false + true + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Program.cs b/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Program.cs new file mode 100644 index 00000000000..b518dfa7739 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Program.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. + +#pragma warning disable S125 // Remove this commented out code + +using Microsoft.Extensions.AI; + +// Use types from each library. + +// Microsoft.Extensions.AI.Ollama +using var b = new OllamaChatClient("http://localhost:11434", "llama3.2"); + +// Microsoft.Extensions.AI.AzureAIInference +// using var a = new Azure.AI.Inference.ChatCompletionClient(new Uri("http://localhost"), new("apikey")); // uncomment once warnings in Azure.AI.Inference are addressed + +// Microsoft.Extensions.AI.OpenAI +// using var c = new OpenAI.OpenAIClient("apikey").AsChatClient("gpt-4o-mini"); // uncomment once warnings in OpenAI are addressed + +// Microsoft.Extensions.AI +AIFunctionFactory.Create(() => { }); + +System.Console.WriteLine("Success!"); From e0c951d0ae8a91a916cfdf49bc577f6415d28677 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 26 Oct 2024 03:34:11 -0400 Subject: [PATCH 068/190] Add changelogs for M.E.AI projects (#5577) --- .../CHANGELOG.md | 19 +++++++++++++++++++ .../CHANGELOG.md | 12 ++++++++++++ .../CHANGELOG.md | 10 ++++++++++ .../CHANGELOG.md | 12 ++++++++++++ .../Microsoft.Extensions.AI/CHANGELOG.md | 17 +++++++++++++++++ 5 files changed, 70 insertions(+) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md create mode 100644 src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md create mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md create mode 100644 src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md new file mode 100644 index 00000000000..6b347a8c09d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -0,0 +1,19 @@ +# Release History + +## 9.0.0-preview.9.24525.1 + +- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. +- Annotated `FunctionCallContent.Exception` and `FunctionResultContent.Exception` as `[JsonIgnore]`, such that they're ignored when serializing instances with `JsonSerializer`. The corresponding constructors accepting an `Exception` were removed. +- Annotated `ChatCompletion.Message` as `[JsonIgnore]`, such that it's ignored when serializing instances with `JsonSerializer`. +- Added the `FunctionCallContent.CreateFromParsedArguments` method. +- Added the `AdditionalPropertiesDictionary.TryGetValue` method. +- Added the `StreamingChatCompletionUpdate.ModelId` property and removed the `AIContent.ModelId` property. +- Renamed the `GenerateAsync` extension method on `IEmbeddingGenerator<,>` to `GenerateEmbeddingsAsync` and updated it to return `Embedding` rather than `GeneratedEmbeddings`. +- Added `GenerateAndZipAsync` and `GenerateEmbeddingVectorAsync` extension methods for `IEmbeddingGenerator<,>`. +- Added the `EmbeddingGeneratorOptions.Dimensions` property. +- Added the `ChatOptions.TopK` property. +- Normalized `null` inputs in `TextContent` to be empty strings. + +## 9.0.0-preview.9.24507.7 + +Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md new file mode 100644 index 00000000000..7929cc7e8b2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md @@ -0,0 +1,12 @@ +# Release History + +## 9.0.0-preview.9.24525.1 + +- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. +- Updated to use Azure.AI.Inference 1.0.0-beta.2. +- Added `AzureAIInferenceEmbeddingGenerator` and corresponding `AsEmbeddingGenerator` extension method. +- Improved handling of assistant messages that include both text and function call content. + +## 9.0.0-preview.9.24507.7 + +Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md new file mode 100644 index 00000000000..ffb35814039 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md @@ -0,0 +1,10 @@ +# Release History + +## 9.0.0-preview.9.24525.1 + +- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. +- Added additional constructors to `OllamaChatClient` and `OllamaEmbeddingGenerator` that accept `string` endpoints, in addition to the existing ones accepting `Uri` endpoints. + +## 9.0.0-preview.9.24507.7 + +Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md new file mode 100644 index 00000000000..179da41a0b0 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -0,0 +1,12 @@ +# Release History + +## 9.0.0-preview.9.24525.1 + +- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. +- Improved handling of system messages that include multiple content items. +- Improved handling of assistant messages that include both text and function call content. +- Fixed handling of streaming updates containing empty payloads. + +## 9.0.0-preview.9.24507.7 + +Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md new file mode 100644 index 00000000000..e2dae2e6e37 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md @@ -0,0 +1,17 @@ +# Release History + +## 9.0.0-preview.9.24525.1 + +- Added new `AIJsonUtilities` and `AIJsonSchemaCreateOptions` classes. +- Made `AIFunctionFactory.Create` safe for use with Native AOT. +- Simplified the set of `AIFunctionFactory.Create` overloads. +- Changed the default for `FunctionInvokingChatClient.ConcurrentInvocation` from `true` to `false`. +- Improved the readability of JSON generated as part of logging. +- Fixed handling of generated JSON schema names when using arrays or generic types. +- Improved `CachingChatClient`'s coalescing of streaming updates, including reduced memory allocation and enhanced metadata propagation. +- Updated `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to conform to the latest 1.28.0 draft specification of the Semantic Conventions for Generative AI systems. +- Improved `CompleteAsync`'s structured output support to handle primitive types, enums, and arrays. + +## 9.0.0-preview.9.24507.7 + +Initial Preview From 0db3caafac27e078f1056ed494f393c01806b761 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 26 Oct 2024 03:34:31 -0400 Subject: [PATCH 069/190] Explicitly reference System.Memory.Data in OpenAI/AzureAIInference projects (#5576) To ensure a recent version is used. --- .../Microsoft.Extensions.AI.AzureAIInference.csproj | 1 + .../Microsoft.Extensions.AI.OpenAI.csproj | 1 + .../Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs | 3 +-- 3 files changed, 3 insertions(+), 2 deletions(-) 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 bfd0b8ea90b..3f9489dbdc7 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 @@ -28,6 +28,7 @@ + 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 87dda461c50..67df978b7d4 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 @@ -25,6 +25,7 @@ + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 42851cdf62f..0562352feb6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -267,8 +267,7 @@ public async IAsyncEnumerable CompleteStreamingAs existing.CallId ??= toolCallUpdate.ToolCallId; existing.Name ??= toolCallUpdate.FunctionName; - if (toolCallUpdate.FunctionArgumentsUpdate is { } update && - !update.ToMemory().IsEmpty) // workaround for https://github.com/dotnet/runtime/issues/68262 in 6.0.0 package + if (toolCallUpdate.FunctionArgumentsUpdate is { } update && !update.ToMemory().IsEmpty) { _ = (existing.Arguments ??= new()).Append(update.ToString()); } From 0ce85d2adf3384dc079531005e719bc387e5f56e Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 26 Oct 2024 03:34:44 -0400 Subject: [PATCH 070/190] Fix AzureAIInferenceEmbeddingGenerator to respect EmbeddingGenerationOptions.Dimensions (#5575) Merge conflict blip. --- .../AzureAIInferenceEmbeddingGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs index 84198e6b2cc..866e55ad87a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs @@ -156,7 +156,7 @@ private EmbeddingsOptions ToAzureAIOptions(IEnumerable inputs, Embedding { EmbeddingsOptions result = new(inputs) { - Dimensions = _dimensions, + Dimensions = options?.Dimensions ?? _dimensions, Model = options?.ModelId ?? Metadata.ModelId, EncodingFormat = format, }; From 8327c2fddd5314a1ffb0d2ceddc10faa3dcb5762 Mon Sep 17 00:00:00 2001 From: Makazeu Date: Wed, 30 Oct 2024 05:49:05 +0800 Subject: [PATCH 071/190] Merge ResourceMonitoringOptions.Linux.cs to ResourceMonitoringOptions.cs (#5580) --- .../ResourceMonitoringOptions.Linux.cs | 40 ------------------- .../ResourceMonitoringOptions.cs | 27 +++++++++++++ 2 files changed, 27 insertions(+), 40 deletions(-) delete mode 100644 src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Linux.cs diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Linux.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Linux.cs deleted file mode 100644 index cb6038826f9..00000000000 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Linux.cs +++ /dev/null @@ -1,40 +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 Microsoft.Shared.Data.Validation; - -namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring; - -public partial class ResourceMonitoringOptions -{ - internal const int MinimumCachingInterval = 100; - internal const int MaximumCachingInterval = 900000; // 15 minutes. - internal static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(5); - - /// - /// Gets or sets the default interval used for refreshing values reported by "process.cpu.utilization" metrics. - /// - /// - /// The default value is 5 seconds. - /// - /// - /// This property is Linux-specific and has no effect on other operating systems. - /// This is the time interval for a metric value to fetch resource utilization data from the operating system. - /// - [TimeSpan(MinimumCachingInterval, MaximumCachingInterval)] - public TimeSpan CpuConsumptionRefreshInterval { get; set; } = DefaultRefreshInterval; - - /// - /// Gets or sets the default interval used for refreshing values reported by "dotnet.process.memory.virtual.utilization" metrics. - /// - /// - /// The default value is 5 seconds. - /// - /// - /// This property is Linux-specific and has no effect on other operating systems. - /// This is the time interval for a metric value to fetch resource utilization data from the operating system. - /// - [TimeSpan(MinimumCachingInterval, MaximumCachingInterval)] - public TimeSpan MemoryConsumptionRefreshInterval { get; set; } = DefaultRefreshInterval; -} diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs index 68dc7cb9ac3..531615bded7 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs @@ -15,8 +15,11 @@ public partial class ResourceMonitoringOptions internal const int MaximumSamplingWindow = 900000; // 15 minutes. internal const int MinimumSamplingPeriod = 1; internal const int MaximumSamplingPeriod = 900000; // 15 minutes. + internal const int MinimumCachingInterval = 100; + internal const int MaximumCachingInterval = 900000; // 15 minutes. internal static readonly TimeSpan DefaultCollectionWindow = TimeSpan.FromSeconds(5); internal static readonly TimeSpan DefaultSamplingInterval = TimeSpan.FromSeconds(1); + internal static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(5); /// /// Gets or sets the maximum time window for which utilization can be requested. @@ -54,4 +57,28 @@ public partial class ResourceMonitoringOptions /// [TimeSpan(MinimumSamplingWindow, MaximumSamplingWindow)] public TimeSpan PublishingWindow { get; set; } = DefaultCollectionWindow; + + /// + /// Gets or sets the default interval used for refreshing values reported by "process.cpu.utilization" metrics. + /// + /// + /// The default value is 5 seconds. + /// + /// + /// This is the time interval for a metric value to fetch resource utilization data from the operating system. + /// + [TimeSpan(MinimumCachingInterval, MaximumCachingInterval)] + public TimeSpan CpuConsumptionRefreshInterval { get; set; } = DefaultRefreshInterval; + + /// + /// Gets or sets the default interval used for refreshing values reported by "dotnet.process.memory.virtual.utilization" metrics. + /// + /// + /// The default value is 5 seconds. + /// + /// + /// This is the time interval for a metric value to fetch resource utilization data from the operating system. + /// + [TimeSpan(MinimumCachingInterval, MaximumCachingInterval)] + public TimeSpan MemoryConsumptionRefreshInterval { get; set; } = DefaultRefreshInterval; } From bf0e0a4c3ebd6734df1e4de7e698022f763c57d4 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 30 Oct 2024 14:40:45 +0000 Subject: [PATCH 072/190] fix exception when generating boolean schemas (#5585) --- .../Utilities/AIJsonUtilities.Schema.cs | 18 ++++++++---------- .../AIJsonUtilitiesTests.cs | 7 +++++++ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs index 46fe45342f2..eb8f0d52a07 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs @@ -138,8 +138,6 @@ public static JsonElement CreateJsonSchema( JsonSerializerOptions? serializerOptions = null, AIJsonSchemaCreateOptions? inferenceOptions = null) { - _ = Throw.IfNull(serializerOptions); - serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; @@ -278,24 +276,24 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) { objSchema.Add(AdditionalPropertiesPropertyName, (JsonNode)false); } - } - - if (ctx.Path.IsEmpty) - { - // We are at the root-level schema node, update/append parameter-specific metadata // 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. // STJ represents .NET integer types as ["string", "integer"], which will then lead to an error. - if (TypeIsArrayContainingInteger(schema)) + if (TypeIsArrayContainingInteger(objSchema)) { // We don't want to emit any array for "type". In this case we know it contains "integer" // so reduce the type to that alone, assuming it's the most specific type. - // This makes schemas for Int32 (etc) work with Ollama + // This makes schemas for Int32 (etc) work with Ollama. JsonObject obj = ConvertSchemaToObject(ref schema); obj[TypePropertyName] = "integer"; _ = obj.Remove(PatternPropertyName); } + } + + if (ctx.Path.IsEmpty) + { + // We are at the root-level schema node, update/append parameter-specific metadata if (!string.IsNullOrWhiteSpace(key.Description)) { @@ -354,7 +352,7 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) } } - private static bool TypeIsArrayContainingInteger(JsonNode schema) + private static bool TypeIsArrayContainingInteger(JsonObject schema) { if (schema["type"] is JsonArray typeArray) { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs index db482d26804..d7ff5c6783e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs @@ -158,4 +158,11 @@ public enum MyEnumValue A = 1, B = 2 } + + [Fact] + public static void ResolveJsonSchema_CanBeBoolean() + { + JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(object)); + Assert.Equal(JsonValueKind.True, schema.ValueKind); + } } From e18a05582e64603a0f2e2871bcf555e476570102 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 30 Oct 2024 13:34:55 -0400 Subject: [PATCH 073/190] Add ImageContent integration test (#5586) --- .../ChatClientIntegrationTests.cs | 31 ++++++++++++++++++ ...oft.Extensions.AI.Integration.Tests.csproj | 4 +++ .../dotnet.png | Bin 0 -> 2140 bytes .../OllamaChatClientIntegrationTests.cs | 2 ++ 4 files changed, 37 insertions(+) create mode 100644 test/Libraries/Microsoft.Extensions.AI.Integration.Tests/dotnet.png diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 0863e31db37..e9c2bd57d65 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; @@ -132,6 +133,27 @@ public virtual async Task CompleteStreamingAsync_UsageDataAvailable() Assert.Equal(usage.Details.InputTokenCount + usage.Details.OutputTokenCount, usage.Details.TotalTokenCount); } + protected virtual string? GetModel_MultiModal_DescribeImage() => null; + + [ConditionalFact] + public virtual async Task MultiModal_DescribeImage() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync( + [ + new(ChatRole.User, + [ + new TextContent("What does this logo say?"), + new ImageContent(GetImageDataUri()), + ]) + ], + new() { ModelId = GetModel_MultiModal_DescribeImage() }); + + Assert.Single(response.Choices); + Assert.True(response.Message.Text?.IndexOf("net", StringComparison.OrdinalIgnoreCase) >= 0, response.Message.Text); + } + [ConditionalFact] public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_Parameterless() { @@ -714,6 +736,15 @@ private enum JobType Unknown, } + private static Uri GetImageDataUri() + { + using Stream? s = typeof(ChatClientIntegrationTests).Assembly.GetManifestResourceStream("Microsoft.Extensions.AI.dotnet.png"); + Assert.NotNull(s); + MemoryStream ms = new(); + s.CopyTo(ms); + return new Uri($"data:image/png;base64,{Convert.ToBase64String(ms.ToArray())}"); + } + [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 e38ccd3268b..04d9bc6d29f 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 @@ -15,6 +15,10 @@ true + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/dotnet.png b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/dotnet.png new file mode 100644 index 0000000000000000000000000000000000000000..fb00ecf91e4b78804c636194bb323bf3710fa1c6 GIT binary patch literal 2140 zcmeHIX;4#F6iz}QBp3(*v25~!F*qzP0ojoU1Wja7KpD}12oa>Xrfece2to*M4{<@1 zP%%ms&>BGn2e5_86S0m&0z?*tFd77$B7y>vzNa&tPJedV>7UNLH{U(qJ@=gNJNL&G zZwy{XCYg~i7z~-iW`$xfSQ!0vwGifWRzWuc0UHB1`G?p&M?Q^4^TQdnylqlFJQMHV zlMy}8cyoMm;xpH^t5o#5@y2-k+Mdkle$k#mS^4OrhKW<@s@vsbjW^%%!+QI>rn#<) z{_bgV=B_HFEH)`LIBec*h>(fF5SlnFpG|4X(PvmP2D6~~`@Sai?5+nm4-!M>!#e`& z78+VFVXe(SMlq!^eg7xWd6boUvM4P&8=5T0wgi! zp<$6Qus?iG)+d;(0n8C!5))oCLL24Oym*Gn(Wz_qt3FCV776EaQ0@8?Z;*X?j^|Sm zn!Z?CE$ZK^Bel^@7%D^$=pOWDB0`L52FJhnR;@@*4VX&YV4t-$6D&!6S(50;t4kpO zSu@uc7lG;JP{4;;gTKszM|=8RA1W@lCalj952n{cQ<2H{5Ho%XwCWD_{Z4eMJK>$z zk!wqDgEouoAc>PS^6@?wtqyq}w+)qQ-k}&oy@>2Jq0>$(&(2yz!kQ$B-aeP1JVAwi zLdDN$6HyHca*;h+GLwCO>;wmB&|!(*}Njk+((AJ3{PGOdx_ow6Z(=O$zl0DfhA9qWN%*0384ZX-%PdlCVsU;^WuLWS6 zFXEf8|L9P?|BxkhKeqYq++65zuqrIXB_=k7X0>One(W+8@jAr|KOqE zA{8rpb%xTK*LyH?wH7r3VwmvsXBR83fP-{+TLwEzY04n+9E2=$_+^Svj1xydpdM_| zOAWL@lsIQ|g;s%d*cN6$i7RV!TL3t`Nippk%z?;2>&ui}v9{Qry++zPRCad)IAFrl zVrN|@85!_xg;Kwn%BW~--xx!>Wk=-d&Bet~zEI_-Jkz#l_a=BNo+EN{T-sGgLK>2R zTl1?679M`Te?VAqflxs-Xlp(?+z^mfcBjCASoPc0Eo+$1Ukv);wnZsCeH&`cr6V+- zlv98Q2P&n*!Bn0NQC5WS;Rxq;oYdzbZwp4})3$-jUb&QY)+bmNVpr+``XIZdFn@{R z-y&lERCUNMmld3Uk>W<<`>Kw>#6lx$oBxuCIFtk;kF{VWJb!KMr(kW0zjXo2SiFoH kLN8~t3iGWE|8=58=$f)MBl>~e*<^I~9RFa}4c} 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() { From 17e5ecdd8d5f40c9c1149ba7acfafb54f6141652 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 31 Oct 2024 10:33:56 -0400 Subject: [PATCH 074/190] Add ChatOptions.Seed (#5587) --- .../ChatCompletion/ChatOptions.cs | 4 ++++ .../AzureAIInferenceChatClient.cs | 6 +----- .../Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs | 6 +++++- .../Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs | 10 +++------- .../ChatCompletion/OpenTelemetryChatClient.cs | 2 +- .../ChatCompletion/ChatOptionsTests.cs | 7 +++++++ .../AzureAIInferenceChatClientTests.cs | 6 +++--- .../OllamaChatClientIntegrationTests.cs | 4 ++-- .../OllamaChatClientTests.cs | 2 +- .../OpenAIChatClientTests.cs | 2 +- 10 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index 4edbed900b4..0a4f6f58296 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -27,6 +27,9 @@ public class ChatOptions /// Gets or sets the presence penalty for generating chat responses. public float? PresencePenalty { get; set; } + /// Gets or sets a seed value used by a service to control the reproducability of results. + public long? Seed { get; set; } + /// /// Gets or sets the response format for the chat request. /// @@ -74,6 +77,7 @@ public virtual ChatOptions Clone() TopK = TopK, FrequencyPenalty = FrequencyPenalty, PresencePenalty = PresencePenalty, + Seed = Seed, ResponseFormat = ResponseFormat, ModelId = ModelId, ToolMode = ToolMode, diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index ecc41140b27..ba76f5c3c90 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -285,6 +285,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, result.NucleusSamplingFactor = options.TopP; result.PresencePenalty = options.PresencePenalty; result.Temperature = options.Temperature; + result.Seed = options.Seed; if (options.StopSequences is { Count: > 0 } stopSequences) { @@ -306,11 +307,6 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, { switch (prop.Key) { - // These properties are strongly-typed on the ChatCompletionsOptions class but not on the ChatOptions class. - case nameof(result.Seed) when prop.Value is long seed: - result.Seed = seed; - break; - // Propagate everything else to the ChatCompletionOptions' AdditionalProperties. default: if (prop.Value is not null) diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 72ddb13b2ac..18ff5d50b7c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -273,7 +273,6 @@ private OllamaChatRequest ToOllamaChatRequest(IList chatMessages, C 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.seed), (options, value) => options.seed = 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); @@ -314,6 +313,11 @@ private OllamaChatRequest ToOllamaChatRequest(IList chatMessages, C { (request.Options ??= new()).top_k = topK; } + + if (options.Seed is long seed) + { + (request.Options ??= new()).seed = seed; + } } return request; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 0562352feb6..985060256f7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -392,6 +392,9 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.TopP = options.TopP; result.PresencePenalty = options.PresencePenalty; result.Temperature = options.Temperature; +#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) { @@ -426,13 +429,6 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.AllowParallelToolCalls = allowParallelToolCalls; } -#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. - if (additionalProperties.TryGetValue(nameof(result.Seed), out long seed)) - { - result.Seed = seed; - } -#pragma warning restore OPENAI001 - if (additionalProperties.TryGetValue(nameof(result.TopLogProbabilityCount), out int topLogProbabilityCountInt)) { result.TopLogProbabilityCount = topLogProbabilityCountInt; diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index 905e756e246..a6dfe53adf5 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -322,7 +322,7 @@ private static ChatCompletion ComposeStreamingUpdatesIntoChatCompletion( _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.PerProvider(_system, "response_format"), responseFormat); } - if (options.AdditionalProperties?.TryGetValue("seed", out long seed) is true) + if (options.Seed is long seed) { _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.PerProvider(_system, "seed"), seed); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs index f83169712c3..fcd40a2f446 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs @@ -19,6 +19,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(options.TopK); Assert.Null(options.FrequencyPenalty); Assert.Null(options.PresencePenalty); + Assert.Null(options.Seed); Assert.Null(options.ResponseFormat); Assert.Null(options.ModelId); Assert.Null(options.StopSequences); @@ -33,6 +34,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(clone.TopK); Assert.Null(clone.FrequencyPenalty); Assert.Null(clone.PresencePenalty); + Assert.Null(options.Seed); Assert.Null(clone.ResponseFormat); Assert.Null(clone.ModelId); Assert.Null(clone.StopSequences); @@ -69,6 +71,7 @@ public void Properties_Roundtrip() options.TopK = 42; options.FrequencyPenalty = 0.4f; options.PresencePenalty = 0.5f; + options.Seed = 12345; options.ResponseFormat = ChatResponseFormat.Json; options.ModelId = "modelId"; options.StopSequences = stopSequences; @@ -82,6 +85,7 @@ public void Properties_Roundtrip() Assert.Equal(42, options.TopK); Assert.Equal(0.4f, options.FrequencyPenalty); Assert.Equal(0.5f, options.PresencePenalty); + Assert.Equal(12345, options.Seed); Assert.Same(ChatResponseFormat.Json, options.ResponseFormat); Assert.Equal("modelId", options.ModelId); Assert.Same(stopSequences, options.StopSequences); @@ -96,6 +100,7 @@ public void Properties_Roundtrip() Assert.Equal(42, clone.TopK); Assert.Equal(0.4f, clone.FrequencyPenalty); Assert.Equal(0.5f, clone.PresencePenalty); + Assert.Equal(12345, options.Seed); Assert.Same(ChatResponseFormat.Json, clone.ResponseFormat); Assert.Equal("modelId", clone.ModelId); Assert.Equal(stopSequences, clone.StopSequences); @@ -126,6 +131,7 @@ public void JsonSerialization_Roundtrips() options.TopK = 42; options.FrequencyPenalty = 0.4f; options.PresencePenalty = 0.5f; + options.Seed = 12345; options.ResponseFormat = ChatResponseFormat.Json; options.ModelId = "modelId"; options.StopSequences = stopSequences; @@ -148,6 +154,7 @@ public void JsonSerialization_Roundtrips() Assert.Equal(42, deserialized.TopK); Assert.Equal(0.4f, deserialized.FrequencyPenalty); Assert.Equal(0.5f, deserialized.PresencePenalty); + Assert.Equal(12345, deserialized.Seed); Assert.Equal(ChatResponseFormat.Json, deserialized.ResponseFormat); Assert.NotSame(ChatResponseFormat.Json, deserialized.ResponseFormat); Assert.Equal("modelId", deserialized.ModelId); diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 4fb5122cc93..f404f5e61ef 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -247,8 +247,8 @@ public async Task MultipleMessages_NonStreaming() ], "presence_penalty": 0.5, "frequency_penalty": 0.75, - "model": "gpt-4o-mini", - "seed": 42 + "seed": 42, + "model": "gpt-4o-mini" } """; @@ -303,7 +303,7 @@ public async Task MultipleMessages_NonStreaming() FrequencyPenalty = 0.75f, PresencePenalty = 0.5f, StopSequences = ["great"], - AdditionalProperties = new() { ["seed"] = 42L }, + Seed = 42, }); Assert.NotNull(response); diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs index ac941623124..4c71690baaf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs @@ -49,7 +49,7 @@ public async Task PromptBasedFunctionCalling_NoArgs() ModelId = "llama3:8b", Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")], Temperature = 0, - AdditionalProperties = new() { ["seed"] = 0L }, + Seed = 0, }); Assert.Single(response.Choices); @@ -83,7 +83,7 @@ public async Task PromptBasedFunctionCalling_WithArgs() { Tools = [stockPriceTool, irrelevantTool], Temperature = 0, - AdditionalProperties = new() { ["seed"] = 0L }, + Seed = 0, }); Assert.Single(response.Choices); diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs index 3e281173c8b..67b10e3f24b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs @@ -254,7 +254,7 @@ public async Task MultipleMessages_NonStreaming() FrequencyPenalty = 0.75f, PresencePenalty = 0.5f, StopSequences = ["great"], - AdditionalProperties = new() { ["seed"] = 42 }, + Seed = 42, }); Assert.NotNull(response); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 691804e5fb8..05d2f5a22ff 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -348,7 +348,7 @@ public async Task MultipleMessages_NonStreaming() FrequencyPenalty = 0.75f, PresencePenalty = 0.5f, StopSequences = ["great"], - AdditionalProperties = new() { ["seed"] = 42 }, + Seed = 42, }); Assert.NotNull(response); From 6811fd5d243127d284fab0b2e8f51f8f6f901c05 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 31 Oct 2024 14:34:50 +0000 Subject: [PATCH 075/190] Lower `AIJsonUtilities` to STJv8 and move to Abstractions library. (#5582) * Lower AIJsonUtilities to STJv8 and move to Abstractions library. * Add README.md --- eng/MSBuild/LegacySupport.props | 4 + eng/packages/TestOnly.props | 2 + ...icrosoft.Extensions.AI.Abstractions.csproj | 1 + .../Utilities/AIJsonSchemaCreateOptions.cs | 0 .../Utilities/AIJsonUtilities.Defaults.cs | 0 .../Utilities/AIJsonUtilities.Schema.cs | 85 +- .../JsonSchemaExporter.JsonSchema.cs | 545 +++++++ .../JsonSchemaExporter/JsonSchemaExporter.cs | 1128 ++++++++++++++ .../JsonSchemaExporterContext.cs | 77 + .../JsonSchemaExporterOptions.cs | 38 + .../NullabilityInfoContext/NullabilityInfo.cs | 75 + .../NullabilityInfoContext.cs | 661 +++++++++ .../NullabilityInfoHelpers.cs | 47 + src/Shared/JsonSchemaExporter/README.md | 11 + src/Shared/Shared.csproj | 6 +- test/Shared/JsonSchemaExporter/Helpers.cs | 91 ++ .../JsonSchemaExporterConfigurationTests.cs | 35 + .../JsonSchemaExporterTests.cs | 148 ++ test/Shared/JsonSchemaExporter/TestData.cs | 55 + test/Shared/JsonSchemaExporter/TestTypes.cs | 1293 +++++++++++++++++ test/Shared/Shared.Tests.csproj | 9 +- 21 files changed, 4294 insertions(+), 17 deletions(-) rename src/Libraries/{Microsoft.Extensions.AI => Microsoft.Extensions.AI.Abstractions}/Utilities/AIJsonSchemaCreateOptions.cs (100%) rename src/Libraries/{Microsoft.Extensions.AI => Microsoft.Extensions.AI.Abstractions}/Utilities/AIJsonUtilities.Defaults.cs (100%) rename src/Libraries/{Microsoft.Extensions.AI => Microsoft.Extensions.AI.Abstractions}/Utilities/AIJsonUtilities.Schema.cs (85%) create mode 100644 src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs create mode 100644 src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs create mode 100644 src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs create mode 100644 src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs create mode 100644 src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs create mode 100644 src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs create mode 100644 src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs create mode 100644 src/Shared/JsonSchemaExporter/README.md create mode 100644 test/Shared/JsonSchemaExporter/Helpers.cs create mode 100644 test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs create mode 100644 test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs create mode 100644 test/Shared/JsonSchemaExporter/TestData.cs create mode 100644 test/Shared/JsonSchemaExporter/TestTypes.cs diff --git a/eng/MSBuild/LegacySupport.props b/eng/MSBuild/LegacySupport.props index 2cfe7b73964..842951ab867 100644 --- a/eng/MSBuild/LegacySupport.props +++ b/eng/MSBuild/LegacySupport.props @@ -43,6 +43,10 @@ + + + + diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 2bde3b34e05..78772d87d09 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -20,6 +20,8 @@ + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index bb1a3b63708..30d5cd84425 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -19,6 +19,7 @@ + true true true true diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonSchemaCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonSchemaCreateOptions.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs similarity index 85% rename from src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs index eb8f0d52a07..cd33a2557af 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs @@ -5,6 +5,9 @@ using System.Collections.Concurrent; using System.ComponentModel; using System.Diagnostics; +#if !NET9_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -16,6 +19,7 @@ #pragma warning disable S1121 // Assignments should not be made from within sub-expressions #pragma warning disable S107 // Methods should not have too many parameters #pragma warning disable S1075 // URIs should not be hardcoded +#pragma warning disable SA1118 // Parameter should not span multiple lines using FunctionParameterKey = ( System.Type? Type, @@ -174,6 +178,11 @@ private static JsonElement GetJsonSchemaCached(JsonSerializerOptions options, Fu #endif } +#if !NET9_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", + Justification = "Pre STJ-9 schema extraction can fail with a runtime exception if certain reflection metadata have been trimmed. " + + "The exception message will guide users to turn off 'IlcTrimMetadata' which resolves all issues.")] +#endif private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, FunctionParameterKey key) { _ = Throw.IfNull(options); @@ -236,16 +245,9 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) const string DefaultPropertyName = "default"; const string RefPropertyName = "$ref"; - // Find the first DescriptionAttribute, starting first from the property, then the parameter, and finally the type itself. - Type descAttrType = typeof(DescriptionAttribute); - var descriptionAttribute = - GetAttrs(descAttrType, ctx.PropertyInfo?.AttributeProvider)?.FirstOrDefault() ?? - GetAttrs(descAttrType, ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider)?.FirstOrDefault() ?? - GetAttrs(descAttrType, ctx.TypeInfo.Type)?.FirstOrDefault(); - - if (descriptionAttribute is DescriptionAttribute attr) + if (ctx.ResolveAttribute() is { } attr) { - ConvertSchemaToObject(ref schema).Insert(0, DescriptionPropertyName, (JsonNode)attr.Description); + ConvertSchemaToObject(ref schema).InsertAtStart(DescriptionPropertyName, (JsonNode)attr.Description); } if (schema is JsonObject objSchema) @@ -268,7 +270,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) // Include the type keyword in enum types if (key.IncludeTypeInEnumSchemas && ctx.TypeInfo.Type.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) { - objSchema.Insert(0, TypePropertyName, "string"); + objSchema.InsertAtStart(TypePropertyName, "string"); } // Disallow additional properties in object schemas @@ -303,7 +305,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) if (index < 0) { // If there's no description property, insert it at the beginning of the doc. - obj.Insert(0, DescriptionPropertyName, (JsonNode)key.Description!); + obj.InsertAtStart(DescriptionPropertyName, (JsonNode)key.Description!); } else { @@ -321,15 +323,12 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) if (key.IncludeSchemaUri) { // The $schema property must be the first keyword in the object - ConvertSchemaToObject(ref schema).Insert(0, SchemaPropertyName, (JsonNode)SchemaKeywordUri); + ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); } } return schema; - static object[]? GetAttrs(Type attrType, ICustomAttributeProvider? provider) => - provider?.GetCustomAttributes(attrType, inherit: false); - static JsonObject ConvertSchemaToObject(ref JsonNode schema) { JsonObject obj; @@ -368,6 +367,62 @@ private static bool TypeIsArrayContainingInteger(JsonObject schema) return false; } + private static void InsertAtStart(this JsonObject jsonObject, string key, JsonNode value) + { +#if NET9_0_OR_GREATER + jsonObject.Insert(0, key, value); +#else + jsonObject.Remove(key); + var copiedEntries = jsonObject.ToArray(); + jsonObject.Clear(); + + jsonObject.Add(key, value); + foreach (var entry in copiedEntries) + { + jsonObject[entry.Key] = entry.Value; + } +#endif + } + +#if !NET9_0_OR_GREATER + private static int IndexOf(this JsonObject jsonObject, string key) + { + int i = 0; + foreach (var entry in jsonObject) + { + if (string.Equals(entry.Key, key, StringComparison.Ordinal)) + { + return i; + } + + i++; + } + + return -1; + } +#endif + + 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 +#if NET9_0_OR_GREATER + GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? + GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? +#else + GetAttrs(ctx.PropertyAttributeProvider) ?? + GetAttrs(ctx.ParameterInfo) ?? +#endif + GetAttrs(ctx.TypeInfo.Type); + + static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => + (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); + } + private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) { Utf8JsonReader reader = new(utf8Json); diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs new file mode 100644 index 00000000000..0f1044fc6eb --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs @@ -0,0 +1,545 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET9_0_OR_GREATER +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json.Nodes; + +namespace System.Text.Json.Schema; + +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable S1144 // Unused private types or members should be removed + +internal static partial class JsonSchemaExporter +{ + // Simple JSON schema representation taken from System.Text.Json + // https://github.com/dotnet/runtime/blob/50d6cad649aad2bfa4069268eddd16fd51ec5cf3/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchema.cs + private sealed class JsonSchema + { + public static JsonSchema False { get; } = new(false); + public static JsonSchema True { get; } = new(true); + + public JsonSchema() + { + } + + private JsonSchema(bool trueOrFalse) + { + _trueOrFalse = trueOrFalse; + } + + public bool IsTrue => _trueOrFalse is true; + public bool IsFalse => _trueOrFalse is false; + private readonly bool? _trueOrFalse; + + public string? Schema + { + get => _schema; + set + { + VerifyMutable(); + _schema = value; + } + } + + private string? _schema; + + public string? Title + { + get => _title; + set + { + VerifyMutable(); + _title = value; + } + } + + private string? _title; + + public string? Description + { + get => _description; + set + { + VerifyMutable(); + _description = value; + } + } + + private string? _description; + + public string? Ref + { + get => _ref; + set + { + VerifyMutable(); + _ref = value; + } + } + + private string? _ref; + + public string? Comment + { + get => _comment; + set + { + VerifyMutable(); + _comment = value; + } + } + + private string? _comment; + + public JsonSchemaType Type + { + get => _type; + set + { + VerifyMutable(); + _type = value; + } + } + + private JsonSchemaType _type = JsonSchemaType.Any; + + public string? Format + { + get => _format; + set + { + VerifyMutable(); + _format = value; + } + } + + private string? _format; + + public string? Pattern + { + get => _pattern; + set + { + VerifyMutable(); + _pattern = value; + } + } + + private string? _pattern; + + public JsonNode? Constant + { + get => _constant; + set + { + VerifyMutable(); + _constant = value; + } + } + + private JsonNode? _constant; + + public List>? Properties + { + get => _properties; + set + { + VerifyMutable(); + _properties = value; + } + } + + private List>? _properties; + + public List? Required + { + get => _required; + set + { + VerifyMutable(); + _required = value; + } + } + + private List? _required; + + public JsonSchema? Items + { + get => _items; + set + { + VerifyMutable(); + _items = value; + } + } + + private JsonSchema? _items; + + public JsonSchema? AdditionalProperties + { + get => _additionalProperties; + set + { + VerifyMutable(); + _additionalProperties = value; + } + } + + private JsonSchema? _additionalProperties; + + public JsonArray? Enum + { + get => _enum; + set + { + VerifyMutable(); + _enum = value; + } + } + + private JsonArray? _enum; + + public JsonSchema? Not + { + get => _not; + set + { + VerifyMutable(); + _not = value; + } + } + + private JsonSchema? _not; + + public List? AnyOf + { + get => _anyOf; + set + { + VerifyMutable(); + _anyOf = value; + } + } + + private List? _anyOf; + + public bool HasDefaultValue + { + get => _hasDefaultValue; + set + { + VerifyMutable(); + _hasDefaultValue = value; + } + } + + private bool _hasDefaultValue; + + public JsonNode? DefaultValue + { + get => _defaultValue; + set + { + VerifyMutable(); + _defaultValue = value; + } + } + + private JsonNode? _defaultValue; + + public int? MinLength + { + get => _minLength; + set + { + VerifyMutable(); + _minLength = value; + } + } + + private int? _minLength; + + public int? MaxLength + { + get => _maxLength; + set + { + VerifyMutable(); + _maxLength = value; + } + } + + private int? _maxLength; + + public JsonSchemaExporterContext? GenerationContext { get; set; } + + public int KeywordCount + { + get + { + if (_trueOrFalse != null) + { + return 0; + } + + int count = 0; + Count(Schema != null); + Count(Ref != null); + Count(Comment != null); + Count(Title != null); + Count(Description != null); + Count(Type != JsonSchemaType.Any); + Count(Format != null); + Count(Pattern != null); + Count(Constant != null); + Count(Properties != null); + Count(Required != null); + Count(Items != null); + Count(AdditionalProperties != null); + Count(Enum != null); + Count(Not != null); + Count(AnyOf != null); + Count(HasDefaultValue); + Count(MinLength != null); + Count(MaxLength != null); + + return count; + + void Count(bool isKeywordSpecified) => count += isKeywordSpecified ? 1 : 0; + } + } + + public void MakeNullable() + { + if (_trueOrFalse != null) + { + return; + } + + if (Type != JsonSchemaType.Any) + { + Type |= JsonSchemaType.Null; + } + } + + public JsonNode ToJsonNode(JsonSchemaExporterOptions options) + { + if (_trueOrFalse is { } boolSchema) + { + return CompleteSchema((JsonNode)boolSchema); + } + + var objSchema = new JsonObject(); + + if (Schema != null) + { + objSchema.Add(JsonSchemaConstants.SchemaPropertyName, Schema); + } + + if (Title != null) + { + objSchema.Add(JsonSchemaConstants.TitlePropertyName, Title); + } + + if (Description != null) + { + objSchema.Add(JsonSchemaConstants.DescriptionPropertyName, Description); + } + + if (Ref != null) + { + objSchema.Add(JsonSchemaConstants.RefPropertyName, Ref); + } + + if (Comment != null) + { + objSchema.Add(JsonSchemaConstants.CommentPropertyName, Comment); + } + + if (MapSchemaType(Type) is JsonNode type) + { + objSchema.Add(JsonSchemaConstants.TypePropertyName, type); + } + + if (Format != null) + { + objSchema.Add(JsonSchemaConstants.FormatPropertyName, Format); + } + + if (Pattern != null) + { + objSchema.Add(JsonSchemaConstants.PatternPropertyName, Pattern); + } + + if (Constant != null) + { + objSchema.Add(JsonSchemaConstants.ConstPropertyName, Constant); + } + + if (Properties != null) + { + var properties = new JsonObject(); + foreach (KeyValuePair property in Properties) + { + properties.Add(property.Key, property.Value.ToJsonNode(options)); + } + + objSchema.Add(JsonSchemaConstants.PropertiesPropertyName, properties); + } + + if (Required != null) + { + var requiredArray = new JsonArray(); + foreach (string requiredProperty in Required) + { + requiredArray.Add((JsonNode)requiredProperty); + } + + objSchema.Add(JsonSchemaConstants.RequiredPropertyName, requiredArray); + } + + if (Items != null) + { + objSchema.Add(JsonSchemaConstants.ItemsPropertyName, Items.ToJsonNode(options)); + } + + if (AdditionalProperties != null) + { + objSchema.Add(JsonSchemaConstants.AdditionalPropertiesPropertyName, AdditionalProperties.ToJsonNode(options)); + } + + if (Enum != null) + { + objSchema.Add(JsonSchemaConstants.EnumPropertyName, Enum); + } + + if (Not != null) + { + objSchema.Add(JsonSchemaConstants.NotPropertyName, Not.ToJsonNode(options)); + } + + if (AnyOf != null) + { + JsonArray anyOfArray = new(); + foreach (JsonSchema schema in AnyOf) + { + anyOfArray.Add(schema.ToJsonNode(options)); + } + + objSchema.Add(JsonSchemaConstants.AnyOfPropertyName, anyOfArray); + } + + if (HasDefaultValue) + { + objSchema.Add(JsonSchemaConstants.DefaultPropertyName, DefaultValue); + } + + if (MinLength is int minLength) + { + objSchema.Add(JsonSchemaConstants.MinLengthPropertyName, (JsonNode)minLength); + } + + if (MaxLength is int maxLength) + { + objSchema.Add(JsonSchemaConstants.MaxLengthPropertyName, (JsonNode)maxLength); + } + + return CompleteSchema(objSchema); + + JsonNode CompleteSchema(JsonNode schema) + { + if (GenerationContext is { } context) + { + Debug.Assert(options.TransformSchemaNode != null, "context should only be populated if a callback is present."); + + // Apply any user-defined transformations to the schema. + return options.TransformSchemaNode!(context, schema); + } + + return schema; + } + } + + public static void EnsureMutable(ref JsonSchema schema) + { + switch (schema._trueOrFalse) + { + case false: + schema = new JsonSchema { Not = JsonSchema.True }; + break; + case true: + schema = new JsonSchema(); + break; + } + } + + private static readonly JsonSchemaType[] _schemaValues = new JsonSchemaType[] + { + // NB the order of these values influences order of types in the rendered schema + JsonSchemaType.String, + JsonSchemaType.Integer, + JsonSchemaType.Number, + JsonSchemaType.Boolean, + JsonSchemaType.Array, + JsonSchemaType.Object, + JsonSchemaType.Null, + }; + + private void VerifyMutable() + { + Debug.Assert(_trueOrFalse is null, "Schema is not mutable"); + } + + private static JsonNode? MapSchemaType(JsonSchemaType schemaType) + { + if (schemaType is JsonSchemaType.Any) + { + return null; + } + + if (ToIdentifier(schemaType) is string identifier) + { + return identifier; + } + + var array = new JsonArray(); + foreach (JsonSchemaType type in _schemaValues) + { + if ((schemaType & type) != 0) + { + array.Add((JsonNode)ToIdentifier(type)!); + } + } + + return array; + + static string? ToIdentifier(JsonSchemaType schemaType) => schemaType switch + { + JsonSchemaType.Null => "null", + JsonSchemaType.Boolean => "boolean", + JsonSchemaType.Integer => "integer", + JsonSchemaType.Number => "number", + JsonSchemaType.String => "string", + JsonSchemaType.Array => "array", + JsonSchemaType.Object => "object", + _ => null, + }; + } + } + + [Flags] + private enum JsonSchemaType + { + Any = 0, // No type declared on the schema + Null = 1, + Boolean = 2, + Integer = 4, + Number = 8, + String = 16, + Array = 32, + Object = 64, + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs new file mode 100644 index 00000000000..9c4b83f8343 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs @@ -0,0 +1,1128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET9_0_OR_GREATER +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reflection; +#if NET +using System.Runtime.InteropServices; +#endif +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable LA0002 // Use 'Microsoft.Shared.Text.NumericExtensions.ToInvariantString' for improved performance +#pragma warning disable S107 // Methods should not have too many parameters +#pragma warning disable S103 // Lines should not be too long +#pragma warning disable S1121 // Assignments should not be made from within sub-expressions +#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable S3358 // Ternary operators should not be nested +#pragma warning disable EA0004 // Make type internal since project is executable + +namespace System.Text.Json.Schema; + +/// +/// Maps .NET types to JSON schema objects using contract metadata from instances. +/// +#if !SHARED_PROJECT +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif +internal static partial class JsonSchemaExporter +{ + // Polyfill implementation of JsonSchemaExporter for System.Text.Json version 8.0.0. + // Uses private reflection to access metadata not available with the older APIs of STJ. + + private const string RequiresUnreferencedCodeMessage = + "Uses private reflection on System.Text.Json components to access converter metadata. " + + "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."; + + /// + /// Generates a JSON schema corresponding to the contract metadata of the specified type. + /// + /// The options instance from which to resolve the contract metadata. + /// The root type for which to generate the JSON schema. + /// The exporterOptions object controlling the schema generation. + /// A new instance defining the JSON schema for . + /// One of the specified parameters is . + /// The parameter contains unsupported exporterOptions. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + public static JsonNode GetJsonSchemaAsNode(this JsonSerializerOptions options, Type type, JsonSchemaExporterOptions? exporterOptions = null) + { + _ = Throw.IfNull(options); + _ = Throw.IfNull(type); + ValidateOptions(options); + + exporterOptions ??= JsonSchemaExporterOptions.Default; + JsonTypeInfo typeInfo = options.GetTypeInfo(type); + return MapRootTypeJsonSchema(typeInfo, exporterOptions); + } + + /// + /// Generates a JSON schema corresponding to the specified contract metadata. + /// + /// The contract metadata for which to generate the schema. + /// The exporterOptions object controlling the schema generation. + /// A new instance defining the JSON schema for . + /// One of the specified parameters is . + /// The parameter contains unsupported exporterOptions. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + public static JsonNode GetJsonSchemaAsNode(this JsonTypeInfo typeInfo, JsonSchemaExporterOptions? exporterOptions = null) + { + _ = Throw.IfNull(typeInfo); + ValidateOptions(typeInfo.Options); + + exporterOptions ??= JsonSchemaExporterOptions.Default; + return MapRootTypeJsonSchema(typeInfo, exporterOptions); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static JsonNode MapRootTypeJsonSchema(JsonTypeInfo typeInfo, JsonSchemaExporterOptions exporterOptions) + { + GenerationState state = new(exporterOptions, typeInfo.Options); + JsonSchema schema = MapJsonSchemaCore(ref state, typeInfo); + return schema.ToJsonNode(exporterOptions); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static JsonSchema MapJsonSchemaCore( + ref GenerationState state, + JsonTypeInfo typeInfo, + Type? parentType = null, + JsonPropertyInfo? propertyInfo = null, + ICustomAttributeProvider? propertyAttributeProvider = null, + ParameterInfo? parameterInfo = null, + bool isNonNullableType = false, + JsonConverter? customConverter = null, + JsonNumberHandling? customNumberHandling = null, + JsonTypeInfo? parentPolymorphicTypeInfo = null, + bool parentPolymorphicTypeContainsTypesWithoutDiscriminator = false, + bool parentPolymorphicTypeIsNonNullable = false, + KeyValuePair? typeDiscriminator = null, + bool cacheResult = true) + { + Debug.Assert(typeInfo.IsReadOnly, "The specified contract must have been made read-only."); + + JsonSchemaExporterContext exporterContext = state.CreateContext(typeInfo, parentPolymorphicTypeInfo, parentType, propertyInfo, parameterInfo, propertyAttributeProvider); + + if (cacheResult && typeInfo.Kind is not JsonTypeInfoKind.None && + state.TryGetExistingJsonPointer(exporterContext, out string? existingJsonPointer)) + { + // The schema context has already been generated in the schema document, return a reference to it. + return CompleteSchema(ref state, new JsonSchema { Ref = existingJsonPointer }); + } + + JsonSchema schema; + JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter; + JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Options.NumberHandling; + + if (!IsBuiltInConverter(effectiveConverter)) + { + // Return a `true` schema for types with user-defined converters. + return CompleteSchema(ref state, JsonSchema.True); + } + + if (parentPolymorphicTypeInfo is null && typeInfo.PolymorphismOptions is { DerivedTypes.Count: > 0 } polyOptions) + { + // This is the base type of a polymorphic type hierarchy. The schema for this type + // will include an "anyOf" property with the schemas for all derived types. + + string typeDiscriminatorKey = polyOptions.TypeDiscriminatorPropertyName; + List derivedTypes = polyOptions.DerivedTypes.ToList(); + + if (!typeInfo.Type.IsAbstract && !derivedTypes.Any(derived => derived.DerivedType == typeInfo.Type)) + { + // For non-abstract base types that haven't been explicitly configured, + // add a trivial schema to the derived types since we should support it. + derivedTypes.Add(new JsonDerivedType(typeInfo.Type)); + } + + bool containsTypesWithoutDiscriminator = derivedTypes.Exists(static derivedTypes => derivedTypes.TypeDiscriminator is null); + JsonSchemaType schemaType = JsonSchemaType.Any; + List? anyOf = new(derivedTypes.Count); + + state.PushSchemaNode(JsonSchemaConstants.AnyOfPropertyName); + + foreach (JsonDerivedType derivedType in derivedTypes) + { + Debug.Assert(derivedType.TypeDiscriminator is null or int or string, "Type discriminator does not have the expected type."); + + KeyValuePair? derivedTypeDiscriminator = null; + if (derivedType.TypeDiscriminator is { } discriminatorValue) + { + JsonNode discriminatorNode = discriminatorValue switch + { + string stringId => (JsonNode)stringId, + _ => (JsonNode)(int)discriminatorValue, + }; + + JsonSchema discriminatorSchema = new() { Constant = discriminatorNode }; + derivedTypeDiscriminator = new(typeDiscriminatorKey, discriminatorSchema); + } + + JsonTypeInfo derivedTypeInfo = typeInfo.Options.GetTypeInfo(derivedType.DerivedType); + + state.PushSchemaNode(anyOf.Count.ToString(CultureInfo.InvariantCulture)); + JsonSchema derivedSchema = MapJsonSchemaCore( + ref state, + derivedTypeInfo, + parentPolymorphicTypeInfo: typeInfo, + typeDiscriminator: derivedTypeDiscriminator, + parentPolymorphicTypeContainsTypesWithoutDiscriminator: containsTypesWithoutDiscriminator, + parentPolymorphicTypeIsNonNullable: isNonNullableType, + cacheResult: false); + + state.PopSchemaNode(); + + // Determine if all derived schemas have the same type. + if (anyOf.Count == 0) + { + schemaType = derivedSchema.Type; + } + else if (schemaType != derivedSchema.Type) + { + schemaType = JsonSchemaType.Any; + } + + anyOf.Add(derivedSchema); + } + + state.PopSchemaNode(); + + if (schemaType is not JsonSchemaType.Any) + { + // If all derived types have the same schema type, we can simplify the schema + // by moving the type keyword to the base schema and removing it from the derived schemas. + foreach (JsonSchema derivedSchema in anyOf) + { + derivedSchema.Type = JsonSchemaType.Any; + + if (derivedSchema.KeywordCount == 0) + { + // if removing the type results in an empty schema, + // remove the anyOf array entirely since it's always true. + anyOf = null; + break; + } + } + } + + schema = new() + { + Type = schemaType, + AnyOf = anyOf, + + // If all derived types have a discriminator, we can require it in the base schema. + Required = containsTypesWithoutDiscriminator ? null : new() { typeDiscriminatorKey }, + }; + + return CompleteSchema(ref state, schema); + } + + if (Nullable.GetUnderlyingType(typeInfo.Type) is Type nullableElementType) + { + JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(nullableElementType); + customConverter = ExtractCustomNullableConverter(customConverter); + schema = MapJsonSchemaCore(ref state, elementTypeInfo, customConverter: customConverter, cacheResult: false); + + if (schema.Enum != null) + { + Debug.Assert(elementTypeInfo.Type.IsEnum, "The enum keyword should only be populated by schemas for enum types."); + schema.Enum.Add(null); // Append null to the enum array. + } + + return CompleteSchema(ref state, schema); + } + + switch (typeInfo.Kind) + { + case JsonTypeInfoKind.Object: + List>? properties = null; + List? required = null; + JsonSchema? additionalProperties = null; + + JsonUnmappedMemberHandling effectiveUnmappedMemberHandling = typeInfo.UnmappedMemberHandling ?? typeInfo.Options.UnmappedMemberHandling; + if (effectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) + { + // Disallow unspecified properties. + additionalProperties = JsonSchema.False; + } + + if (typeDiscriminator is { } typeDiscriminatorPair) + { + (properties = new()).Add(typeDiscriminatorPair); + if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) + { + // Require the discriminator here since it's not common to all derived types. + (required = new()).Add(typeDiscriminatorPair.Key); + } + } + + Func? parameterInfoMapper = ResolveJsonConstructorParameterMapper(typeInfo); + + state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + if (property is { Get: null, Set: null } or { IsExtensionData: true }) + { + continue; // Skip JsonIgnored properties and extension data + } + + JsonNumberHandling? propertyNumberHandling = property.NumberHandling ?? effectiveNumberHandling; + JsonTypeInfo propertyTypeInfo = typeInfo.Options.GetTypeInfo(property.PropertyType); + + // Resolve the attribute provider for the property. + ICustomAttributeProvider? attributeProvider = ResolveAttributeProvider(typeInfo.Type, property); + + // Declare the property as nullable if either getter or setter are nullable. + bool isNonNullableProperty = false; + if (attributeProvider is MemberInfo memberInfo) + { + NullabilityInfo nullabilityInfo = state.NullabilityInfoContext.GetMemberNullability(memberInfo); + isNonNullableProperty = + (property.Get is null || nullabilityInfo.ReadState is NullabilityState.NotNull) && + (property.Set is null || nullabilityInfo.WriteState is NullabilityState.NotNull); + } + + bool isRequired = property.IsRequired; + bool hasDefaultValue = false; + JsonNode? defaultValue = null; + + ParameterInfo? associatedParameter = parameterInfoMapper?.Invoke(property); + if (associatedParameter != null) + { + ResolveParameterInfo( + associatedParameter, + propertyTypeInfo, + state.NullabilityInfoContext, + out hasDefaultValue, + out defaultValue, + out bool isNonNullableParameter, + ref isRequired); + + isNonNullableProperty &= isNonNullableParameter; + } + + state.PushSchemaNode(property.Name); + JsonSchema propertySchema = MapJsonSchemaCore( + ref state, + propertyTypeInfo, + parentType: typeInfo.Type, + propertyInfo: property, + parameterInfo: associatedParameter, + propertyAttributeProvider: attributeProvider, + isNonNullableType: isNonNullableProperty, + customConverter: property.CustomConverter, + customNumberHandling: propertyNumberHandling); + + state.PopSchemaNode(); + + if (hasDefaultValue) + { + JsonSchema.EnsureMutable(ref propertySchema); + propertySchema.DefaultValue = defaultValue; + propertySchema.HasDefaultValue = true; + } + + (properties ??= new()).Add(new(property.Name, propertySchema)); + + if (isRequired) + { + (required ??= new()).Add(property.Name); + } + } + + state.PopSchemaNode(); + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Object, + Properties = properties, + Required = required, + AdditionalProperties = additionalProperties, + }); + + case JsonTypeInfoKind.Enumerable: + Type elementType = GetElementType(typeInfo); + JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementType); + + if (typeDiscriminator is null) + { + state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); + JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); + state.PopSchemaNode(); + + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Array, + Items = items.IsTrue ? null : items, + }); + } + else + { + // Polymorphic enumerable types are represented using a wrapping object: + // { "$type" : "discriminator", "$values" : [element1, element2, ...] } + // Which corresponds to the schema + // { "properties" : { "$type" : { "const" : "discriminator" }, "$values" : { "type" : "array", "items" : { ... } } } } + const string ValuesKeyword = "$values"; + + state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); + state.PushSchemaNode(ValuesKeyword); + state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); + + JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); + + state.PopSchemaNode(); + state.PopSchemaNode(); + state.PopSchemaNode(); + + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Object, + Properties = new() + { + typeDiscriminator.Value, + new(ValuesKeyword, + new JsonSchema + { + Type = JsonSchemaType.Array, + Items = items.IsTrue ? null : items, + }), + }, + Required = parentPolymorphicTypeContainsTypesWithoutDiscriminator ? new() { typeDiscriminator.Value.Key } : null, + }); + } + + case JsonTypeInfoKind.Dictionary: + Type valueType = GetElementType(typeInfo); + JsonTypeInfo valueTypeInfo = typeInfo.Options.GetTypeInfo(valueType); + + List>? dictProps = null; + List? dictRequired = null; + + if (typeDiscriminator is { } dictDiscriminator) + { + dictProps = new() { dictDiscriminator }; + if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) + { + // Require the discriminator here since it's not common to all derived types. + dictRequired = new() { dictDiscriminator.Key }; + } + } + + state.PushSchemaNode(JsonSchemaConstants.AdditionalPropertiesPropertyName); + JsonSchema valueSchema = MapJsonSchemaCore(ref state, valueTypeInfo, customNumberHandling: effectiveNumberHandling); + state.PopSchemaNode(); + + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Object, + Properties = dictProps, + Required = dictRequired, + AdditionalProperties = valueSchema.IsTrue ? null : valueSchema, + }); + + default: + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.None, "The default case should handle unrecognize type kinds."); + + if (_simpleTypeSchemaFactories.TryGetValue(typeInfo.Type, out Func? simpleTypeSchemaFactory)) + { + schema = simpleTypeSchemaFactory(effectiveNumberHandling); + } + else if (typeInfo.Type.IsEnum) + { + schema = GetEnumConverterSchema(typeInfo, effectiveConverter); + } + else + { + schema = JsonSchema.True; + } + + return CompleteSchema(ref state, schema); + } + + JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) + { + if (schema.Ref is null) + { + // 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. + bool isNullableSchema = (propertyInfo != null || parameterInfo != null) + ? !isNonNullableType + : CanBeNull(typeInfo.Type) && !parentPolymorphicTypeIsNonNullable && !state.ExporterOptions.TreatNullObliviousAsNonNullable; + + if (isNullableSchema) + { + schema.MakeNullable(); + } + } + + if (state.ExporterOptions.TransformSchemaNode != null) + { + // Prime the schema for invocation by the JsonNode transformer. + schema.GenerationContext = exporterContext; + } + + return schema; + } + } + + private readonly ref struct GenerationState + { + private const int DefaultMaxDepth = 64; + private readonly List _currentPath = new(); + private readonly Dictionary<(JsonTypeInfo, JsonPropertyInfo?), string[]> _generated = new(); + private readonly int _maxDepth; + + public GenerationState(JsonSchemaExporterOptions exporterOptions, JsonSerializerOptions options, NullabilityInfoContext? nullabilityInfoContext = null) + { + ExporterOptions = exporterOptions; + NullabilityInfoContext = nullabilityInfoContext ?? new(); + _maxDepth = options.MaxDepth is 0 ? DefaultMaxDepth : options.MaxDepth; + } + + public JsonSchemaExporterOptions ExporterOptions { get; } + public NullabilityInfoContext NullabilityInfoContext { get; } + public int CurrentDepth => _currentPath.Count; + + public void PushSchemaNode(string nodeId) + { + if (CurrentDepth == _maxDepth) + { + ThrowHelpers.ThrowInvalidOperationException_MaxDepthReached(); + } + + _currentPath.Add(nodeId); + } + + public void PopSchemaNode() + { + _currentPath.RemoveAt(_currentPath.Count - 1); + } + + /// + /// Registers the current schema node generation context; if it has already been generated return a JSON pointer to its location. + /// + public bool TryGetExistingJsonPointer(in JsonSchemaExporterContext context, [NotNullWhen(true)] out string? existingJsonPointer) + { + (JsonTypeInfo, JsonPropertyInfo?) key = (context.TypeInfo, context.PropertyInfo); +#if NET + ref string[]? pathToSchema = ref CollectionsMarshal.GetValueRefOrAddDefault(_generated, key, out bool exists); +#else + bool exists = _generated.TryGetValue(key, out string[]? pathToSchema); +#endif + if (exists) + { + existingJsonPointer = FormatJsonPointer(pathToSchema); + return true; + } +#if NET + pathToSchema = context._path; +#else + _generated[key] = context._path; +#endif + existingJsonPointer = null; + return false; + } + + public JsonSchemaExporterContext CreateContext( + JsonTypeInfo typeInfo, + JsonTypeInfo? baseTypeInfo, + Type? declaringType, + JsonPropertyInfo? propertyInfo, + ParameterInfo? parameterInfo, + ICustomAttributeProvider? propertyAttributeProvider) + { + return new JsonSchemaExporterContext(typeInfo, baseTypeInfo, declaringType, propertyInfo, parameterInfo, propertyAttributeProvider, _currentPath.ToArray()); + } + + private static string FormatJsonPointer(ReadOnlySpan path) + { + if (path.IsEmpty) + { + return "#"; + } + + StringBuilder sb = new(); + _ = sb.Append('#'); + + for (int i = 0; i < path.Length; i++) + { + string segment = path[i]; + if (segment.AsSpan().IndexOfAny('~', '/') != -1) + { +#pragma warning disable CA1307 // Specify StringComparison for clarity + segment = segment.Replace("~", "~0").Replace("/", "~1"); +#pragma warning restore CA1307 + } + + _ = sb.Append('/'); + _ = sb.Append(segment); + } + + return sb.ToString(); + } + } + + private static readonly Dictionary> _simpleTypeSchemaFactories = new() + { + [typeof(object)] = _ => JsonSchema.True, + [typeof(bool)] = _ => new JsonSchema { Type = JsonSchemaType.Boolean }, + [typeof(byte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(ushort)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(uint)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(ulong)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(sbyte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(short)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(int)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(long)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(float)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), + [typeof(double)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), + [typeof(decimal)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling), +#if NET6_0_OR_GREATER + [typeof(Half)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), +#endif +#if NET7_0_OR_GREATER + [typeof(UInt128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(Int128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), +#endif + [typeof(char)] = _ => new JsonSchema { Type = JsonSchemaType.String, MinLength = 1, MaxLength = 1 }, + [typeof(string)] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(byte[])] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(Memory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(ReadOnlyMemory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(DateTime)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, + [typeof(DateTimeOffset)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, + [typeof(TimeSpan)] = _ => new JsonSchema + { + Comment = "Represents a System.TimeSpan value.", + Type = JsonSchemaType.String, + Pattern = @"^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$", + }, + +#if NET6_0_OR_GREATER + [typeof(DateOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date" }, + [typeof(TimeOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "time" }, +#endif + [typeof(Guid)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uuid" }, + [typeof(Uri)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uri" }, + [typeof(Version)] = _ => new JsonSchema + { + Comment = "Represents a version string.", + Type = JsonSchemaType.String, + Pattern = @"^\d+(\.\d+){1,3}$", + }, + + [typeof(JsonDocument)] = _ => JsonSchema.True, + [typeof(JsonElement)] = _ => JsonSchema.True, + [typeof(JsonNode)] = _ => JsonSchema.True, + [typeof(JsonValue)] = _ => JsonSchema.True, + [typeof(JsonObject)] = _ => new JsonSchema { Type = JsonSchemaType.Object }, + [typeof(JsonArray)] = _ => new JsonSchema { Type = JsonSchemaType.Array }, + }; + + // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/JsonPrimitiveConverter.cs#L36-L69 + private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, JsonNumberHandling numberHandling, bool isIeeeFloatingPoint = false) + { + Debug.Assert(schemaType is JsonSchemaType.Integer or JsonSchemaType.Number, "schema type must be number or integer"); + Debug.Assert(!isIeeeFloatingPoint || schemaType is JsonSchemaType.Number, "If specifying IEEE the schema type must be number"); + + string? pattern = null; + + if ((numberHandling & (JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)) != 0) + { + pattern = schemaType is JsonSchemaType.Integer + ? @"^-?(?:0|[1-9]\d*)$" + : isIeeeFloatingPoint + ? @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$" + : @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; + + schemaType |= JsonSchemaType.String; + } + + if (isIeeeFloatingPoint && (numberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0) + { + return new JsonSchema + { + AnyOf = new() + { + new JsonSchema { Type = schemaType, Pattern = pattern }, + new JsonSchema { Enum = new() { (JsonNode)"NaN", (JsonNode)"Infinity", (JsonNode)"-Infinity" } }, + }, + }; + } + + return new JsonSchema { Type = schemaType, Pattern = pattern }; + } + + // Uses reflection to determine the element type of an enumerable or dictionary type + // Workaround for https://github.com/dotnet/runtime/issues/77306#issuecomment-2007887560 + private static Type GetElementType(JsonTypeInfo typeInfo) + { + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary, "TypeInfo must be of collection type"); + _elementTypeProperty ??= typeof(JsonTypeInfo).GetProperty("ElementType", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return (Type)_elementTypeProperty?.GetValue(typeInfo)!; + } + + private static PropertyInfo? _elementTypeProperty; + + // The .NET 8 source generator doesn't populate attribute providers for properties + // cf. https://github.com/dotnet/runtime/issues/100095 + // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property + // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static ICustomAttributeProvider? ResolveAttributeProvider(Type? declaringType, JsonPropertyInfo? propertyInfo) + { + if (declaringType is null || propertyInfo is null) + { + return null; + } + + if (propertyInfo.AttributeProvider is { } provider) + { + return provider; + } + + _memberNameProperty ??= typeof(JsonPropertyInfo).GetProperty("MemberName", BindingFlags.Instance | BindingFlags.NonPublic)!; + var memberName = (string?)_memberNameProperty.GetValue(propertyInfo); + if (memberName is not null) + { + return declaringType.GetMember(memberName, MemberTypes.Property | MemberTypes.Field, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).FirstOrDefault(); + } + + return null; + } + + private static PropertyInfo? _memberNameProperty; + + // Uses reflection to determine any custom converters specified for the element of a nullable type. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static JsonConverter? ExtractCustomNullableConverter(JsonConverter? converter) + { + Debug.Assert(converter is null || IsBuiltInConverter(converter), "If specified the converter must be built-in."); + + // There is unfortunately no way in which we can obtain the element converter from a nullable converter without resorting to private reflection + // https://github.com/dotnet/runtime/blob/release/8.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/NullableConverter.cs#L15-L17 + Type? converterType = converter?.GetType(); + if (converterType?.Name == "NullableConverter`1") + { + FieldInfo elementConverterField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_elementConverter"); + return (JsonConverter)elementConverterField!.GetValue(converter)!; + } + + return null; + } + + private static void ValidateOptions(JsonSerializerOptions options) + { + if (options.ReferenceHandler == ReferenceHandler.Preserve) + { + ThrowHelpers.ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported(); + } + + options.MakeReadOnly(); + } + + private static void ResolveParameterInfo( + ParameterInfo parameter, + JsonTypeInfo parameterTypeInfo, + NullabilityInfoContext nullabilityInfoContext, + out bool hasDefaultValue, + out JsonNode? defaultValue, + out bool isNonNullable, + ref bool isRequired) + { + Debug.Assert(parameterTypeInfo.Type == parameter.ParameterType, "The typeInfo type must match the ParameterInfo type."); + + // Incorporate the nullability information from the parameter. + isNonNullable = nullabilityInfoContext.GetParameterNullability(parameter) is NullabilityState.NotNull; + + if (parameter.HasDefaultValue) + { + // Append the default value to the description. + object? defaultVal = parameter.GetNormalizedDefaultValue(); + defaultValue = JsonSerializer.SerializeToNode(defaultVal, parameterTypeInfo); + hasDefaultValue = true; + } + else + { + // Parameter is not optional, mark as required. + isRequired = true; + defaultValue = null; + hasDefaultValue = false; + } + } + + // Uses reflection to determine schema for enum types + // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs#L498-L521 + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConverter converter) + { + Debug.Assert(typeInfo.Type.IsEnum && IsBuiltInConverter(converter), "must be using a built-in enum converter."); + + if (converter is JsonConverterFactory factory) + { + converter = factory.CreateConverter(typeInfo.Type, typeInfo.Options)!; + } + + Type converterType = converter.GetType(); + FieldInfo converterOptionsField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_converterOptions"); + FieldInfo namingPolicyField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_namingPolicy"); + + const int EnumConverterOptionsAllowStrings = 1; + var converterOptions = (int)converterOptionsField!.GetValue(converter)!; + if ((converterOptions & EnumConverterOptionsAllowStrings) != 0) + { + // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings + // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity. + + if (typeInfo.Type.GetCustomAttribute() is not null) + { + // Do not report enum values in case of flags. + return new() { Type = JsonSchemaType.String }; + } + + var namingPolicy = (JsonNamingPolicy?)namingPolicyField!.GetValue(converter)!; + JsonArray enumValues = new(); + foreach (string name in Enum.GetNames(typeInfo.Type)) + { + // This does not account for custom names specified via the new + // JsonStringEnumMemberNameAttribute introduced in .NET 9. + string effectiveName = namingPolicy?.ConvertName(name) ?? name; + enumValues.Add((JsonNode)effectiveName); + } + + return new() { Enum = enumValues }; + } + + return new() { Type = JsonSchemaType.Integer }; + } + + private static NullabilityState GetParameterNullability(this NullabilityInfoContext context, ParameterInfo parameterInfo) + { +#if !NET9_0_OR_GREATER + // Workaround for https://github.com/dotnet/runtime/issues/92487 + if (GetGenericParameterDefinition(parameterInfo) is { ParameterType: { IsGenericParameter: true } typeParam }) + { + // Step 1. Look for nullable annotations on the type parameter. + if (GetNullableFlags(typeParam) is byte[] flags) + { + return TranslateByte(flags[0]); + } + + // Step 2. Look for nullable annotations on the generic method declaration. + if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) + { + return TranslateByte(flag); + } + + // Step 3. Look for nullable annotations on the generic method declaration. + if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) + { + return TranslateByte(flag2); + } + + // Default to nullable. + return NullabilityState.Nullable; + +#if NETCOREAPP + [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", + Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] +#endif + static byte[]? GetNullableFlags(MemberInfo member) + { + Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => + { + Type attrType = attr.GetType(); + return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableAttribute"; + }); + + return (byte[])attr?.GetType().GetField("NullableFlags")?.GetValue(attr)!; + } + + [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", + Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] + static byte? GetNullableContextFlag(MemberInfo member) + { + Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => + { + Type attrType = attr.GetType(); + return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableContextAttribute"; + }); + + return (byte?)attr?.GetType().GetField("Flag")?.GetValue(attr)!; + } + +#pragma warning disable S109 // Magic numbers should not be used + static NullabilityState TranslateByte(byte b) => b switch + { + 1 => NullabilityState.NotNull, + 2 => NullabilityState.Nullable, + _ => NullabilityState.Unknown + }; +#pragma warning restore S109 // Magic numbers should not be used + } + + static ParameterInfo GetGenericParameterDefinition(ParameterInfo parameter) + { + if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } + or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) + { + var genericMethod = (MethodBase)GetGenericMemberDefinition(parameter.Member); + return genericMethod.GetParameters()[parameter.Position]; + } + + return parameter; + } + + [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", + Justification = "Looking up the generic member definition of the provided member.")] + static MemberInfo GetGenericMemberDefinition(MemberInfo member) + { + if (member is Type type) + { + return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; + } + + if (member.DeclaringType!.IsConstructedGenericType) + { + const BindingFlags AllMemberFlags = + BindingFlags.Static | BindingFlags.Instance | + BindingFlags.Public | BindingFlags.NonPublic; + + return member.DeclaringType.GetGenericTypeDefinition() + .GetMember(member.Name, AllMemberFlags) + .First(m => m.MetadataToken == member.MetadataToken); + } + + if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) + { + return method.GetGenericMethodDefinition(); + } + + return member; + } +#endif + return context.Create(parameterInfo).WriteState; + } + + // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 + private static object? GetNormalizedDefaultValue(this ParameterInfo parameterInfo) + { + Type parameterType = parameterInfo.ParameterType; + object? defaultValue = parameterInfo.DefaultValue; + + if (defaultValue is null) + { + return null; + } + + // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. + if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) + { + return null; + } + + // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly + // cf. https://github.com/dotnet/runtime/issues/68647 + if (parameterType.IsEnum) + { + return Enum.ToObject(parameterType, defaultValue); + } + + if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) + { + return Enum.ToObject(underlyingType, defaultValue); + } + + return defaultValue; + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static FieldInfo GetPrivateFieldWithPotentiallyTrimmedMetadata(this Type type, string fieldName) + { + FieldInfo? field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + if (field is null) + { + throw new InvalidOperationException( + $"Could not resolve metadata for field '{fieldName}' in type '{type}'. " + + "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."); + } + + return field; + } + + // Resolves the parameters of the deserialization constructor for a type, if they exist. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static Func? ResolveJsonConstructorParameterMapper(JsonTypeInfo typeInfo) + { + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object, "Should only be passed object JSON kinds."); + + if (typeInfo.Properties.Count > 0 && + typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used + typeInfo.Type.TryGetDeserializationConstructor(useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) + { + ParameterInfo[]? parameters = ctor?.GetParameters(); + if (parameters?.Length > 0) + { + Dictionary dict = new(parameters.Length); + foreach (ParameterInfo parameter in parameters) + { + if (parameter.Name is not null) + { + // We don't care about null parameter names or conflicts since they + // would have already been rejected by JsonTypeInfo exporterOptions. + dict[new(parameter.Name, parameter.ParameterType)] = parameter; + } + } + + return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; + } + } + + return null; + } + + // Parameter to property matching semantics as declared in + // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 + private readonly struct ParameterLookupKey : IEquatable + { + public ParameterLookupKey(string name, Type type) + { + Name = name; + Type = type; + } + + public string Name { get; } + public Type Type { get; } + + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); + public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); + public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); + } + + // Resolves the deserialization constructor for a type using logic copied from + // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static bool TryGetDeserializationConstructor( + this Type type, + bool useDefaultCtorInAnnotatedStructs, + out ConstructorInfo? deserializationCtor) + { + ConstructorInfo? ctorWithAttribute = null; + ConstructorInfo? publicParameterlessCtor = null; + ConstructorInfo? lonePublicCtor = null; + + ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + if (constructors.Length == 1) + { + lonePublicCtor = constructors[0]; + } + + foreach (ConstructorInfo constructor in constructors) + { + if (HasJsonConstructorAttribute(constructor)) + { + if (ctorWithAttribute != null) + { + deserializationCtor = null; + return false; + } + + ctorWithAttribute = constructor; + } + else if (constructor.GetParameters().Length == 0) + { + publicParameterlessCtor = constructor; + } + } + + // Search for non-public ctors with [JsonConstructor]. + foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (HasJsonConstructorAttribute(constructor)) + { + if (ctorWithAttribute != null) + { + deserializationCtor = null; + return false; + } + + ctorWithAttribute = constructor; + } + } + + // Structs will use default constructor if attribute isn't used. + if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) + { + deserializationCtor = null; + return true; + } + + deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; + return true; + + static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => + constructorInfo.GetCustomAttribute() != null; + } + + private static bool IsBuiltInConverter(JsonConverter converter) => + converter.GetType().Assembly == typeof(JsonConverter).Assembly; + + // Resolves the nullable reference type annotations for a property or field, + // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. + private static NullabilityInfo GetMemberNullability(this NullabilityInfoContext context, MemberInfo memberInfo) + { + Debug.Assert(memberInfo is PropertyInfo or FieldInfo, "Member must be property or field."); + return memberInfo is PropertyInfo prop + ? context.Create(prop) + : context.Create((FieldInfo)memberInfo); + } + + private static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; + + private static class JsonSchemaConstants + { + public const string SchemaPropertyName = "$schema"; + public const string RefPropertyName = "$ref"; + public const string CommentPropertyName = "$comment"; + public const string TitlePropertyName = "title"; + public const string DescriptionPropertyName = "description"; + public const string TypePropertyName = "type"; + public const string FormatPropertyName = "format"; + public const string PatternPropertyName = "pattern"; + public const string PropertiesPropertyName = "properties"; + public const string RequiredPropertyName = "required"; + public const string ItemsPropertyName = "items"; + public const string AdditionalPropertiesPropertyName = "additionalProperties"; + public const string EnumPropertyName = "enum"; + public const string NotPropertyName = "not"; + public const string AnyOfPropertyName = "anyOf"; + public const string ConstPropertyName = "const"; + public const string DefaultPropertyName = "default"; + public const string MinLengthPropertyName = "minLength"; + public const string MaxLengthPropertyName = "maxLength"; + } + + private static class ThrowHelpers + { + [DoesNotReturn] + public static void ThrowInvalidOperationException_MaxDepthReached() => + throw new InvalidOperationException("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting."); + + [DoesNotReturn] + public static void ThrowInvalidOperationException_TrimmedMethodParameters(MethodBase method) => + throw new InvalidOperationException($"The parameters for method '{method}' have been trimmed away."); + + [DoesNotReturn] + public static void ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported() => + throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs new file mode 100644 index 00000000000..3602ee46df4 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.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. + +#if !NET9_0_OR_GREATER +using System; +using System.Reflection; +using System.Text.Json.Serialization.Metadata; + +namespace System.Text.Json.Schema; + +/// +/// Defines the context in which a JSON schema within a type graph is being generated. +/// +#if !SHARED_PROJECT +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif +internal readonly struct JsonSchemaExporterContext +{ +#pragma warning disable IDE1006 // Naming Styles + internal readonly string[] _path; +#pragma warning restore IDE1006 // Naming Styles + + internal JsonSchemaExporterContext( + JsonTypeInfo typeInfo, + JsonTypeInfo? baseTypeInfo, + Type? declaringType, + JsonPropertyInfo? propertyInfo, + ParameterInfo? parameterInfo, + ICustomAttributeProvider? propertyAttributeProvider, + string[] path) + { + TypeInfo = typeInfo; + DeclaringType = declaringType; + BaseTypeInfo = baseTypeInfo; + PropertyInfo = propertyInfo; + ParameterInfo = parameterInfo; + PropertyAttributeProvider = propertyAttributeProvider; + _path = path; + } + + /// + /// Gets the path to the schema document currently being generated. + /// + public ReadOnlySpan Path => _path; + + /// + /// Gets the for the type being processed. + /// + public JsonTypeInfo TypeInfo { get; } + + /// + /// Gets the declaring type of the property or parameter being processed. + /// + public Type? DeclaringType { get; } + + /// + /// Gets the type info for the polymorphic base type if generated as a derived type. + /// + public JsonTypeInfo? BaseTypeInfo { get; } + + /// + /// Gets the if the schema is being generated for a property. + /// + public JsonPropertyInfo? PropertyInfo { get; } + + /// + /// Gets the if a constructor parameter + /// has been associated with the accompanying . + /// + public ParameterInfo? ParameterInfo { get; } + + /// + /// Gets the corresponding to the property or field being processed. + /// + public ICustomAttributeProvider? PropertyAttributeProvider { get; } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs new file mode 100644 index 00000000000..53a269ea612 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET9_0_OR_GREATER +using System; +using System.Text.Json.Nodes; + +namespace System.Text.Json.Schema; + +/// +/// Controls the behavior of the class. +/// +#if !SHARED_PROJECT +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif +internal sealed class JsonSchemaExporterOptions +{ + /// + /// Gets the default configuration object used by . + /// + public static JsonSchemaExporterOptions Default { get; } = new(); + + /// + /// Gets a value indicating whether non-nullable schemas should be generated for null oblivious reference types. + /// + /// + /// Defaults to . Due to restrictions in the run-time representation of nullable reference types + /// most occurrences are null oblivious and are treated as nullable by the serializer. A notable exception to that rule + /// are nullability annotations of field, property and constructor parameters which are represented in the contract metadata. + /// + public bool TreatNullObliviousAsNonNullable { get; init; } + + /// + /// Gets a callback that is invoked for every schema that is generated within the type graph. + /// + public Func? TransformSchemaNode { get; init; } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs new file mode 100644 index 00000000000..bd9b132cd0f --- /dev/null +++ b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable SA1623 // Property summary documentation should match accessors + +namespace System.Reflection +{ + /// + /// A class that represents nullability info. + /// + [ExcludeFromCodeCoverage] + internal sealed class NullabilityInfo + { + internal NullabilityInfo(Type type, NullabilityState readState, NullabilityState writeState, + NullabilityInfo? elementType, NullabilityInfo[] typeArguments) + { + Type = type; + ReadState = readState; + WriteState = writeState; + ElementType = elementType; + GenericTypeArguments = typeArguments; + } + + /// + /// The of the member or generic parameter + /// to which this NullabilityInfo belongs. + /// + public Type Type { get; } + + /// + /// The nullability read state of the member. + /// + public NullabilityState ReadState { get; internal set; } + + /// + /// The nullability write state of the member. + /// + public NullabilityState WriteState { get; internal set; } + + /// + /// If the member type is an array, gives the of the elements of the array, null otherwise. + /// + public NullabilityInfo? ElementType { get; } + + /// + /// If the member type is a generic type, gives the array of for each type parameter. + /// + public NullabilityInfo[] GenericTypeArguments { get; } + } + + /// + /// An enum that represents nullability state. + /// + internal enum NullabilityState + { + /// + /// Nullability context not enabled (oblivious). + /// + Unknown, + + /// + /// Non nullable value or reference type. + /// + NotNull, + + /// + /// Nullable value or reference type. + /// + Nullable, + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs new file mode 100644 index 00000000000..3edee1b9cb8 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs @@ -0,0 +1,661 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET6_0_OR_GREATER +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable S109 // Magic numbers should not be used +#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable S4136 // Method overloads should be grouped together +#pragma warning disable SA1202 // Elements should be ordered by access +#pragma warning disable IDE1006 // Naming Styles + +namespace System.Reflection +{ + /// + /// Provides APIs for populating nullability information/context from reflection members: + /// , , and . + /// + [ExcludeFromCodeCoverage] + internal sealed class NullabilityInfoContext + { + private const string CompilerServicesNameSpace = "System.Runtime.CompilerServices"; + private readonly Dictionary _publicOnlyModules = new(); + private readonly Dictionary _context = new(); + + [Flags] + private enum NotAnnotatedStatus + { + None = 0x0, // no restriction, all members annotated + Private = 0x1, // private members not annotated + Internal = 0x2, // internal members not annotated + } + + private NullabilityState? GetNullableContext(MemberInfo? memberInfo) + { + while (memberInfo != null) + { + if (_context.TryGetValue(memberInfo, out NullabilityState state)) + { + return state; + } + + foreach (CustomAttributeData attribute in memberInfo.GetCustomAttributesData()) + { + if (attribute.AttributeType.Name == "NullableContextAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNameSpace && + attribute.ConstructorArguments.Count == 1) + { + state = TranslateByte(attribute.ConstructorArguments[0].Value); + _context.Add(memberInfo, state); + return state; + } + } + + memberInfo = memberInfo.DeclaringType; + } + + return null; + } + + /// + /// Populates for the given . + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the parameterInfo parameter is null. + /// . + public NullabilityInfo Create(ParameterInfo parameterInfo) + { + IList attributes = parameterInfo.GetCustomAttributesData(); + NullableAttributeStateParser parser = parameterInfo.Member is MethodBase method && IsPrivateOrInternalMethodAndAnnotationDisabled(method) + ? NullableAttributeStateParser.Unknown + : CreateParser(attributes); + NullabilityInfo nullability = GetNullabilityInfo(parameterInfo.Member, parameterInfo.ParameterType, parser); + + if (nullability.ReadState != NullabilityState.Unknown) + { + CheckParameterMetadataType(parameterInfo, nullability); + } + + CheckNullabilityAttributes(nullability, attributes); + return nullability; + } + + private void CheckParameterMetadataType(ParameterInfo parameter, NullabilityInfo nullability) + { + ParameterInfo? metaParameter; + MemberInfo metaMember; + + switch (parameter.Member) + { + case ConstructorInfo ctor: + var metaCtor = (ConstructorInfo)GetMemberMetadataDefinition(ctor); + metaMember = metaCtor; + metaParameter = GetMetaParameter(metaCtor, parameter); + break; + + case MethodInfo method: + MethodInfo metaMethod = GetMethodMetadataDefinition(method); + metaMember = metaMethod; + metaParameter = string.IsNullOrEmpty(parameter.Name) ? metaMethod.ReturnParameter : GetMetaParameter(metaMethod, parameter); + break; + + default: + return; + } + + if (metaParameter != null) + { + CheckGenericParameters(nullability, metaMember, metaParameter.ParameterType, parameter.Member.ReflectedType); + } + } + + private static ParameterInfo? GetMetaParameter(MethodBase metaMethod, ParameterInfo parameter) + { + var parameters = metaMethod.GetParameters(); + for (int i = 0; i < parameters.Length; i++) + { + if (parameter.Position == i && + parameter.Name == parameters[i].Name) + { + return parameters[i]; + } + } + + return null; + } + + private static MethodInfo GetMethodMetadataDefinition(MethodInfo method) + { + if (method.IsGenericMethod && !method.IsGenericMethodDefinition) + { + method = method.GetGenericMethodDefinition(); + } + + return (MethodInfo)GetMemberMetadataDefinition(method); + } + + private static void CheckNullabilityAttributes(NullabilityInfo nullability, IList attributes) + { + var codeAnalysisReadState = NullabilityState.Unknown; + var codeAnalysisWriteState = NullabilityState.Unknown; + + foreach (CustomAttributeData attribute in attributes) + { + if (attribute.AttributeType.Namespace == "System.Diagnostics.CodeAnalysis") + { + if (attribute.AttributeType.Name == "NotNullAttribute") + { + codeAnalysisReadState = NullabilityState.NotNull; + } + else if ((attribute.AttributeType.Name == "MaybeNullAttribute" || + attribute.AttributeType.Name == "MaybeNullWhenAttribute") && + codeAnalysisReadState == NullabilityState.Unknown && + !IsValueTypeOrValueTypeByRef(nullability.Type)) + { + codeAnalysisReadState = NullabilityState.Nullable; + } + else if (attribute.AttributeType.Name == "DisallowNullAttribute") + { + codeAnalysisWriteState = NullabilityState.NotNull; + } + else if (attribute.AttributeType.Name == "AllowNullAttribute" && + codeAnalysisWriteState == NullabilityState.Unknown && + !IsValueTypeOrValueTypeByRef(nullability.Type)) + { + codeAnalysisWriteState = NullabilityState.Nullable; + } + } + } + + if (codeAnalysisReadState != NullabilityState.Unknown) + { + nullability.ReadState = codeAnalysisReadState; + } + + if (codeAnalysisWriteState != NullabilityState.Unknown) + { + nullability.WriteState = codeAnalysisWriteState; + } + } + + /// + /// Populates for the given . + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the propertyInfo parameter is null. + /// . + public NullabilityInfo Create(PropertyInfo propertyInfo) + { + MethodInfo? getter = propertyInfo.GetGetMethod(true); + MethodInfo? setter = propertyInfo.GetSetMethod(true); + bool annotationsDisabled = (getter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(getter)) + && (setter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(setter)); + NullableAttributeStateParser parser = annotationsDisabled ? NullableAttributeStateParser.Unknown : CreateParser(propertyInfo.GetCustomAttributesData()); + NullabilityInfo nullability = GetNullabilityInfo(propertyInfo, propertyInfo.PropertyType, parser); + + if (getter != null) + { + CheckNullabilityAttributes(nullability, getter.ReturnParameter.GetCustomAttributesData()); + } + else + { + nullability.ReadState = NullabilityState.Unknown; + } + + if (setter != null) + { + CheckNullabilityAttributes(nullability, setter.GetParameters().Last().GetCustomAttributesData()); + } + else + { + nullability.WriteState = NullabilityState.Unknown; + } + + return nullability; + } + + private bool IsPrivateOrInternalMethodAndAnnotationDisabled(MethodBase method) + { + if ((method.IsPrivate || method.IsFamilyAndAssembly || method.IsAssembly) && + IsPublicOnly(method.IsPrivate, method.IsFamilyAndAssembly, method.IsAssembly, method.Module)) + { + return true; + } + + return false; + } + + /// + /// Populates for the given . + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the eventInfo parameter is null. + /// . + public NullabilityInfo Create(EventInfo eventInfo) + { + return GetNullabilityInfo(eventInfo, eventInfo.EventHandlerType!, CreateParser(eventInfo.GetCustomAttributesData())); + } + + /// + /// Populates for the given + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the fieldInfo parameter is null. + /// . + public NullabilityInfo Create(FieldInfo fieldInfo) + { + IList attributes = fieldInfo.GetCustomAttributesData(); + NullableAttributeStateParser parser = IsPrivateOrInternalFieldAndAnnotationDisabled(fieldInfo) ? NullableAttributeStateParser.Unknown : CreateParser(attributes); + NullabilityInfo nullability = GetNullabilityInfo(fieldInfo, fieldInfo.FieldType, parser); + CheckNullabilityAttributes(nullability, attributes); + return nullability; + } + + private bool IsPrivateOrInternalFieldAndAnnotationDisabled(FieldInfo fieldInfo) + { + if ((fieldInfo.IsPrivate || fieldInfo.IsFamilyAndAssembly || fieldInfo.IsAssembly) && + IsPublicOnly(fieldInfo.IsPrivate, fieldInfo.IsFamilyAndAssembly, fieldInfo.IsAssembly, fieldInfo.Module)) + { + return true; + } + + return false; + } + + private bool IsPublicOnly(bool isPrivate, bool isFamilyAndAssembly, bool isAssembly, Module module) + { + if (!_publicOnlyModules.TryGetValue(module, out NotAnnotatedStatus value)) + { + value = PopulateAnnotationInfo(module.GetCustomAttributesData()); + _publicOnlyModules.Add(module, value); + } + + if (value == NotAnnotatedStatus.None) + { + return false; + } + + if (((isPrivate || isFamilyAndAssembly) && value.HasFlag(NotAnnotatedStatus.Private)) || + (isAssembly && value.HasFlag(NotAnnotatedStatus.Internal))) + { + return true; + } + + return false; + } + + private static NotAnnotatedStatus PopulateAnnotationInfo(IList customAttributes) + { + foreach (CustomAttributeData attribute in customAttributes) + { + if (attribute.AttributeType.Name == "NullablePublicOnlyAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNameSpace && + attribute.ConstructorArguments.Count == 1) + { + if (attribute.ConstructorArguments[0].Value is bool boolValue && boolValue) + { + return NotAnnotatedStatus.Internal | NotAnnotatedStatus.Private; + } + else + { + return NotAnnotatedStatus.Private; + } + } + } + + return NotAnnotatedStatus.None; + } + + private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser) + { + int index = 0; + NullabilityInfo nullability = GetNullabilityInfo(memberInfo, type, parser, ref index); + + if (nullability.ReadState != NullabilityState.Unknown) + { + TryLoadGenericMetaTypeNullability(memberInfo, nullability); + } + + return nullability; + } + + private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser, ref int index) + { + NullabilityState state = NullabilityState.Unknown; + NullabilityInfo? elementState = null; + NullabilityInfo[] genericArgumentsState = Array.Empty(); + Type underlyingType = type; + + if (underlyingType.IsByRef || underlyingType.IsPointer) + { + underlyingType = underlyingType.GetElementType()!; + } + + if (underlyingType.IsValueType) + { + if (Nullable.GetUnderlyingType(underlyingType) is { } nullableUnderlyingType) + { + underlyingType = nullableUnderlyingType; + state = NullabilityState.Nullable; + } + else + { + state = NullabilityState.NotNull; + } + + if (underlyingType.IsGenericType) + { + ++index; + } + } + else + { + if (!parser.ParseNullableState(index++, ref state) + && GetNullableContext(memberInfo) is { } contextState) + { + state = contextState; + } + + if (underlyingType.IsArray) + { + elementState = GetNullabilityInfo(memberInfo, underlyingType.GetElementType()!, parser, ref index); + } + } + + if (underlyingType.IsGenericType) + { + Type[] genericArguments = underlyingType.GetGenericArguments(); + genericArgumentsState = new NullabilityInfo[genericArguments.Length]; + + for (int i = 0; i < genericArguments.Length; i++) + { + genericArgumentsState[i] = GetNullabilityInfo(memberInfo, genericArguments[i], parser, ref index); + } + } + + return new NullabilityInfo(type, state, state, elementState, genericArgumentsState); + } + + private static NullableAttributeStateParser CreateParser(IList customAttributes) + { + foreach (CustomAttributeData attribute in customAttributes) + { + if (attribute.AttributeType.Name == "NullableAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNameSpace && + attribute.ConstructorArguments.Count == 1) + { + return new NullableAttributeStateParser(attribute.ConstructorArguments[0].Value); + } + } + + return new NullableAttributeStateParser(null); + } + + private void TryLoadGenericMetaTypeNullability(MemberInfo memberInfo, NullabilityInfo nullability) + { + MemberInfo? metaMember = GetMemberMetadataDefinition(memberInfo); + Type? metaType = null; + if (metaMember is FieldInfo field) + { + metaType = field.FieldType; + } + else if (metaMember is PropertyInfo property) + { + metaType = GetPropertyMetaType(property); + } + + if (metaType != null) + { + CheckGenericParameters(nullability, metaMember!, metaType, memberInfo.ReflectedType); + } + } + + private static MemberInfo GetMemberMetadataDefinition(MemberInfo member) + { + Type? type = member.DeclaringType; + if ((type != null) && type.IsGenericType && !type.IsGenericTypeDefinition) + { + return NullabilityInfoHelpers.GetMemberWithSameMetadataDefinitionAs(type.GetGenericTypeDefinition(), member); + } + + return member; + } + + private static Type GetPropertyMetaType(PropertyInfo property) + { + if (property.GetGetMethod(true) is MethodInfo method) + { + return method.ReturnType; + } + + return property.GetSetMethod(true)!.GetParameters()[0].ParameterType; + } + + private void CheckGenericParameters(NullabilityInfo nullability, MemberInfo metaMember, Type metaType, Type? reflectedType) + { + if (metaType.IsGenericParameter) + { + if (nullability.ReadState == NullabilityState.NotNull) + { + _ = TryUpdateGenericParameterNullability(nullability, metaType, reflectedType); + } + } + else if (metaType.ContainsGenericParameters) + { + if (nullability.GenericTypeArguments.Length > 0) + { + Type[] genericArguments = metaType.GetGenericArguments(); + + for (int i = 0; i < genericArguments.Length; i++) + { + CheckGenericParameters(nullability.GenericTypeArguments[i], metaMember, genericArguments[i], reflectedType); + } + } + else if (nullability.ElementType is { } elementNullability && metaType.IsArray) + { + CheckGenericParameters(elementNullability, metaMember, metaType.GetElementType()!, reflectedType); + } + + // We could also follow this branch for metaType.IsPointer, but since pointers must be unmanaged this + // will be a no-op regardless + else if (metaType.IsByRef) + { + CheckGenericParameters(nullability, metaMember, metaType.GetElementType()!, reflectedType); + } + } + } + + private bool TryUpdateGenericParameterNullability(NullabilityInfo nullability, Type genericParameter, Type? reflectedType) + { + Debug.Assert(genericParameter.IsGenericParameter, "must be generic parameter"); + + if (reflectedType is not null + && !genericParameter.IsGenericMethodParameter() + && TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, reflectedType, reflectedType)) + { + return true; + } + + if (IsValueTypeOrValueTypeByRef(nullability.Type)) + { + return true; + } + + var state = NullabilityState.Unknown; + if (CreateParser(genericParameter.GetCustomAttributesData()).ParseNullableState(0, ref state)) + { + nullability.ReadState = state; + nullability.WriteState = state; + return true; + } + + if (GetNullableContext(genericParameter) is { } contextState) + { + nullability.ReadState = contextState; + nullability.WriteState = contextState; + return true; + } + + return false; + } + + private bool TryUpdateGenericTypeParameterNullabilityFromReflectedType(NullabilityInfo nullability, Type genericParameter, Type context, Type reflectedType) + { + Debug.Assert(genericParameter.IsGenericParameter && !genericParameter.IsGenericMethodParameter(), "must be generic parameter"); + + Type contextTypeDefinition = context.IsGenericType && !context.IsGenericTypeDefinition ? context.GetGenericTypeDefinition() : context; + if (genericParameter.DeclaringType == contextTypeDefinition) + { + return false; + } + + Type? baseType = contextTypeDefinition.BaseType; + if (baseType is null) + { + return false; + } + + if (!baseType.IsGenericType + || (baseType.IsGenericTypeDefinition ? baseType : baseType.GetGenericTypeDefinition()) != genericParameter.DeclaringType) + { + return TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, baseType, reflectedType); + } + + Type[] genericArguments = baseType.GetGenericArguments(); + Type genericArgument = genericArguments[genericParameter.GenericParameterPosition]; + if (genericArgument.IsGenericParameter) + { + return TryUpdateGenericParameterNullability(nullability, genericArgument, reflectedType); + } + + NullableAttributeStateParser parser = CreateParser(contextTypeDefinition.GetCustomAttributesData()); + int nullabilityStateIndex = 1; // start at 1 since index 0 is the type itself + for (int i = 0; i < genericParameter.GenericParameterPosition; i++) + { + nullabilityStateIndex += CountNullabilityStates(genericArguments[i]); + } + + return TryPopulateNullabilityInfo(nullability, parser, ref nullabilityStateIndex); + + static int CountNullabilityStates(Type type) + { + Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; + if (underlyingType.IsGenericType) + { + int count = 1; + foreach (Type genericArgument in underlyingType.GetGenericArguments()) + { + count += CountNullabilityStates(genericArgument); + } + + return count; + } + + if (underlyingType.HasElementType) + { + return (underlyingType.IsArray ? 1 : 0) + CountNullabilityStates(underlyingType.GetElementType()!); + } + + return type.IsValueType ? 0 : 1; + } + } + +#pragma warning disable SA1204 // Static elements should appear before instance elements + private static bool TryPopulateNullabilityInfo(NullabilityInfo nullability, NullableAttributeStateParser parser, ref int index) +#pragma warning restore SA1204 // Static elements should appear before instance elements + { + bool isValueType = IsValueTypeOrValueTypeByRef(nullability.Type); + if (!isValueType) + { + var state = NullabilityState.Unknown; + if (!parser.ParseNullableState(index, ref state)) + { + return false; + } + + nullability.ReadState = state; + nullability.WriteState = state; + } + + if (!isValueType || (Nullable.GetUnderlyingType(nullability.Type) ?? nullability.Type).IsGenericType) + { + index++; + } + + if (nullability.GenericTypeArguments.Length > 0) + { + foreach (NullabilityInfo genericTypeArgumentNullability in nullability.GenericTypeArguments) + { + _ = TryPopulateNullabilityInfo(genericTypeArgumentNullability, parser, ref index); + } + } + else if (nullability.ElementType is { } elementTypeNullability) + { + _ = TryPopulateNullabilityInfo(elementTypeNullability, parser, ref index); + } + + return true; + } + + private static NullabilityState TranslateByte(object? value) + { + return value is byte b ? TranslateByte(b) : NullabilityState.Unknown; + } + + private static NullabilityState TranslateByte(byte b) => + b switch + { + 1 => NullabilityState.NotNull, + 2 => NullabilityState.Nullable, + _ => NullabilityState.Unknown + }; + + private static bool IsValueTypeOrValueTypeByRef(Type type) => + type.IsValueType || ((type.IsByRef || type.IsPointer) && type.GetElementType()!.IsValueType); + + private readonly struct NullableAttributeStateParser + { + private static readonly object UnknownByte = (byte)0; + + private readonly object? _nullableAttributeArgument; + + public NullableAttributeStateParser(object? nullableAttributeArgument) + { + _nullableAttributeArgument = nullableAttributeArgument; + } + + public static NullableAttributeStateParser Unknown => new(UnknownByte); + + public bool ParseNullableState(int index, ref NullabilityState state) + { + switch (_nullableAttributeArgument) + { + case byte b: + state = TranslateByte(b); + return true; + case ReadOnlyCollection args + when index < args.Count && args[index].Value is byte elementB: + state = TranslateByte(elementB); + return true; + default: + return false; + } + } + } + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs new file mode 100644 index 00000000000..1ee573a0020 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable IDE1006 // Naming Styles +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace System.Reflection +{ + /// + /// Polyfills for System.Private.CoreLib internals. + /// + [ExcludeFromCodeCoverage] + internal static class NullabilityInfoHelpers + { + public static MemberInfo GetMemberWithSameMetadataDefinitionAs(Type type, MemberInfo member) + { + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + foreach (var info in type.GetMembers(all)) + { + if (info.HasSameMetadataDefinitionAs(member)) + { + return info; + } + } + + throw new MissingMemberException(type.FullName, member.Name); + } + + // https://github.com/dotnet/runtime/blob/main/src/coreclr/System.Private.CoreLib/src/System/Reflection/MemberInfo.Internal.cs + public static bool HasSameMetadataDefinitionAs(this MemberInfo target, MemberInfo other) + { + return target.MetadataToken == other.MetadataToken && + target.Module.Equals(other.Module); + } + + // https://github.com/dotnet/runtime/issues/23493 + public static bool IsGenericMethodParameter(this Type target) + { + return target.IsGenericParameter && + target.DeclaringMethod != null; + } + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/README.md b/src/Shared/JsonSchemaExporter/README.md new file mode 100644 index 00000000000..1a4d13c5841 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/README.md @@ -0,0 +1,11 @@ +# JsonSchemaExporter + +Provides a polyfill for the [.NET 9 `JsonSchemaExporter` component](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/extract-schema) that is compatible with all supported targets using System.Text.Json version 8. + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/src/Shared/Shared.csproj b/src/Shared/Shared.csproj index f6cbb03ea83..58ec4eda535 100644 --- a/src/Shared/Shared.csproj +++ b/src/Shared/Shared.csproj @@ -12,7 +12,7 @@ true true true - true + true true true true @@ -33,6 +33,10 @@ + + + + diff --git a/test/Shared/JsonSchemaExporter/Helpers.cs b/test/Shared/JsonSchemaExporter/Helpers.cs new file mode 100644 index 00000000000..a925c1721f0 --- /dev/null +++ b/test/Shared/JsonSchemaExporter/Helpers.cs @@ -0,0 +1,91 @@ +// 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.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Json.Schema; +using Json.Schema.Generation; +using Xunit.Sdk; + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +internal static partial class Helpers +{ + public static void AssertValidJsonSchema(Type type, string? expectedJsonSchema, JsonNode actualJsonSchema) + { + // If an expected schema is provided, use that. Otherwise, generate a schema from the type. + JsonNode? expectedJsonSchemaNode = expectedJsonSchema != null + ? JsonNode.Parse(expectedJsonSchema, documentOptions: new() { CommentHandling = JsonCommentHandling.Skip }) + : JsonSerializer.SerializeToNode(new JsonSchemaBuilder().FromType(type), Context.Default.JsonSchema); + + // Trim the $schema property from actual schema since it's not included by the generator. + (actualJsonSchema as JsonObject)?.Remove("$schema"); + + if (!JsonNode.DeepEquals(expectedJsonSchemaNode, actualJsonSchema)) + { + throw new XunitException($""" + Generated schema does not match the expected specification. + Expected: + {FormatJson(expectedJsonSchemaNode)} + Actual: + {FormatJson(actualJsonSchema)} + """); + } + } + + public static void AssertDocumentMatchesSchema(JsonNode schema, JsonNode? instance) + { + EvaluationResults results = EvaluateSchemaCore(schema, instance); + if (!results.IsValid) + { + IEnumerable errors = results.Details + .Where(d => d.HasErrors) + .SelectMany(d => d.Errors!.Select(error => $"Path:${d.InstanceLocation} {error.Key}:{error.Value}")); + + throw new XunitException($""" + Instance JSON document does not match the specified schema. + Schema: + {FormatJson(schema)} + Instance: + {FormatJson(instance)} + Errors: + {string.Join(Environment.NewLine, errors)} + """); + } + } + + public static void AssertDoesNotMatchSchema(JsonNode schema, JsonNode? instance) + { + EvaluationResults results = EvaluateSchemaCore(schema, instance); + if (results.IsValid) + { + throw new XunitException($""" + Instance JSON document matches the specified schema. + Schema: + {FormatJson(schema)} + Instance: + {FormatJson(instance)} + """); + } + } + + private static EvaluationResults EvaluateSchemaCore(JsonNode schema, JsonNode? instance) + { + JsonSchema jsonSchema = JsonSerializer.Deserialize(schema, Context.Default.JsonSchema)!; + EvaluationOptions options = new() { OutputFormat = OutputFormat.List }; + return jsonSchema.Evaluate(instance, options); + } + + private static string FormatJson(JsonNode? node) => + JsonSerializer.Serialize(node, Context.Default.JsonNode!); + + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(JsonNode))] + [JsonSerializable(typeof(JsonSchema))] + [JsonSourceGenerationOptions(WriteIndented = true)] + private partial class Context : JsonSerializerContext; +} diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs new file mode 100644 index 00000000000..1d2b6caa74e --- /dev/null +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Schema; +using Xunit; + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +public static class JsonSchemaExporterConfigurationTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public static void JsonSchemaExporterOptions_DefaultValues(bool useSingleton) + { + JsonSchemaExporterOptions configuration = useSingleton ? JsonSchemaExporterOptions.Default : new(); + Assert.False(configuration.TreatNullObliviousAsNonNullable); + Assert.Null(configuration.TransformSchemaNode); + } + + [Fact] + public static void JsonSchemaExporterOptions_Singleton_ReturnsSameInstance() + { + Assert.Same(JsonSchemaExporterOptions.Default, JsonSchemaExporterOptions.Default); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public static void JsonSchemaExporterOptions_TreatNullObliviousAsNonNullable(bool treatNullObliviousAsNonNullable) + { + JsonSchemaExporterOptions configuration = new() { TreatNullObliviousAsNonNullable = treatNullObliviousAsNonNullable }; + Assert.Equal(treatNullObliviousAsNonNullable, configuration.TreatNullObliviousAsNonNullable); + } +} diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs new file mode 100644 index 00000000000..d526025d5ba --- /dev/null +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs @@ -0,0 +1,148 @@ +// 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.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +#if !NET9_0_OR_GREATER +using System.Xml.Linq; +#endif +using Xunit; + +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable xUnit1000 // Test classes must be public + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +public abstract class JsonSchemaExporterTests +{ + protected abstract JsonSerializerOptions Options { get; } + + [Theory] + [MemberData(nameof(TestTypes.GetTestData), MemberType = typeof(TestTypes))] + public void TestTypes_GeneratesExpectedJsonSchema(ITestData testData) + { + JsonSerializerOptions options = testData.Options is { } opts + ? new(opts) { TypeInfoResolver = Options.TypeInfoResolver } + : Options; + + JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); + Helpers.AssertValidJsonSchema(testData.Type, testData.ExpectedJsonSchema, schema); + } + + [Theory] + [MemberData(nameof(TestTypes.GetTestDataUsingAllValues), MemberType = typeof(TestTypes))] + public void TestTypes_SerializedValueMatchesGeneratedSchema(ITestData testData) + { + JsonSerializerOptions options = testData.Options is { } opts + ? new(opts) { TypeInfoResolver = Options.TypeInfoResolver } + : Options; + + JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); + JsonNode? instance = JsonSerializer.SerializeToNode(testData.Value, testData.Type, options); + Helpers.AssertDocumentMatchesSchema(schema, instance); + } + + [Theory] + [InlineData(typeof(string), "string")] + [InlineData(typeof(int[]), "array")] + [InlineData(typeof(Dictionary), "object")] + [InlineData(typeof(TestTypes.SimplePoco), "object")] + public void TreatNullObliviousAsNonNullable_True_MarksAllReferenceTypesAsNonNullable(Type referenceType, string expectedType) + { + Assert.True(!referenceType.IsValueType); + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; + JsonNode schema = Options.GetJsonSchemaAsNode(referenceType, config); + JsonValue type = Assert.IsAssignableFrom(schema["type"]); + Assert.Equal(expectedType, (string)type!); + } + + [Theory] + [InlineData(typeof(int), "integer")] + [InlineData(typeof(double), "number")] + [InlineData(typeof(bool), "boolean")] + [InlineData(typeof(ImmutableArray), "array")] + [InlineData(typeof(TestTypes.StructDictionary), "object")] + [InlineData(typeof(TestTypes.SimpleRecordStruct), "object")] + public void TreatNullObliviousAsNonNullable_True_DoesNotImpactNonReferenceTypes(Type referenceType, string expectedType) + { + Assert.True(referenceType.IsValueType); + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; + JsonNode schema = Options.GetJsonSchemaAsNode(referenceType, config); + JsonValue value = Assert.IsAssignableFrom(schema["type"]); + Assert.Equal(expectedType, (string)value!); + } + +#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/108764 gets backported + [Fact] + public void CanGenerateXElementSchema() + { + JsonNode schema = Options.GetJsonSchemaAsNode(typeof(XElement)); + Assert.True(schema.ToJsonString().Length < 100_000); + } +#endif + + [Fact] + public void TreatNullObliviousAsNonNullable_True_DoesNotImpactObjectType() + { + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; + JsonNode schema = Options.GetJsonSchemaAsNode(typeof(object), config); + Assert.False(schema is JsonObject jObj && jObj.ContainsKey("type")); + } + + [Fact] + public void TypeWithDisallowUnmappedMembers_AdditionalPropertiesFailValidation() + { + JsonNode schema = Options.GetJsonSchemaAsNode(typeof(TestTypes.PocoDisallowingUnmappedMembers)); + JsonNode? jsonWithUnmappedProperties = JsonNode.Parse("""{ "UnmappedProperty" : {} }"""); + Helpers.AssertDoesNotMatchSchema(schema, jsonWithUnmappedProperties); + } + + [Fact] + public void GetJsonSchema_NullInputs_ThrowsArgumentNullException() + { + Assert.Throws(() => ((JsonSerializerOptions)null!).GetJsonSchemaAsNode(typeof(int))); + Assert.Throws(() => Options.GetJsonSchemaAsNode(type: null!)); + Assert.Throws(() => ((JsonTypeInfo)null!).GetJsonSchemaAsNode()); + } + + [Fact] + public void GetJsonSchema_NoResolver_ThrowInvalidOperationException() + { + var options = new JsonSerializerOptions(); + Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(int))); + } + + [Fact] + public void MaxDepth_SetToZero_NonTrivialSchema_ThrowsInvalidOperationException() + { + JsonSerializerOptions options = new(Options) { MaxDepth = 1 }; + var ex = Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(TestTypes.SimplePoco))); + Assert.Contains("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting.", ex.Message); + } + + [Fact] + public void ReferenceHandlePreserve_Enabled_ThrowsNotSupportedException() + { + var options = new JsonSerializerOptions(Options) { ReferenceHandler = ReferenceHandler.Preserve }; + options.MakeReadOnly(); + + var ex = Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(TestTypes.SimplePoco))); + Assert.Contains("ReferenceHandler.Preserve", ex.Message); + } +} + +public sealed class ReflectionJsonSchemaExporterTests : JsonSchemaExporterTests +{ + protected override JsonSerializerOptions Options => JsonSerializerOptions.Default; +} + +public sealed class SourceGenJsonSchemaExporterTests : JsonSchemaExporterTests +{ + protected override JsonSerializerOptions Options => TestTypes.TestTypesContext.Default.Options; +} diff --git a/test/Shared/JsonSchemaExporter/TestData.cs b/test/Shared/JsonSchemaExporter/TestData.cs new file mode 100644 index 00000000000..6b2c9d841a3 --- /dev/null +++ b/test/Shared/JsonSchemaExporter/TestData.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Schema; + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +internal sealed record TestData( + T? Value, + IEnumerable? AdditionalValues = null, + [StringSyntax("Json")] string? ExpectedJsonSchema = null, + JsonSchemaExporterOptions? ExporterOptions = null, + JsonSerializerOptions? Options = null) + : ITestData +{ + public Type Type => typeof(T); + object? ITestData.Value => Value; + object? ITestData.ExporterOptions => ExporterOptions; + + IEnumerable ITestData.GetTestDataForAllValues() + { + yield return this; + + if (AdditionalValues != null) + { + foreach (T? value in AdditionalValues) + { + yield return this with { Value = value, AdditionalValues = null }; + } + } + } +} + +public interface ITestData +{ + Type Type { get; } + + object? Value { get; } + + /// + /// Gets the expected JSON schema for the value. + /// Fall back to JsonSchemaGenerator as the source of truth if null. + /// + string? ExpectedJsonSchema { get; } + + object? ExporterOptions { get; } + + JsonSerializerOptions? Options { get; } + + IEnumerable GetTestDataForAllValues(); +} diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs new file mode 100644 index 00000000000..4615143aa78 --- /dev/null +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -0,0 +1,1293 @@ +// 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; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; +using System.Xml.Linq; + +#pragma warning disable SA1118 // Parameter should not span multiple lines +#pragma warning disable JSON001 // Comments not allowed +#pragma warning disable S2344 // Enumeration type names should not have "Flags" or "Enum" suffixes +#pragma warning disable SA1502 // Element should not be on a single line +#pragma warning disable SA1136 // Enum values should be on separate lines +#pragma warning disable SA1133 // Do not combine attributes +#pragma warning disable S3604 // Member initializer values should not be redundant +#pragma warning disable SA1515 // Single-line comment should be preceded by blank line +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable +#pragma warning disable S1121 // Assignments should not be made from within sub-expressions +#pragma warning disable IDE0073 // The file header is missing or not located at the top of the file +#pragma warning disable SA1402 // File may only contain a single type + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +public static partial class TestTypes +{ + public static IEnumerable GetTestData() => GetTestDataCore().Select(t => new object[] { t }); + + public static IEnumerable GetTestDataUsingAllValues() => + GetTestDataCore() + .SelectMany(t => t.GetTestDataForAllValues()) + .Select(t => new object[] { t }); + + public static IEnumerable GetTestDataCore() + { + // Primitives and built-in types + yield return new TestData( + Value: new(), + AdditionalValues: [null, 42, false, 3.14, 3.14M, new int[] { 1, 2, 3 }, new SimpleRecord(1, "str", false, 3.14)], + ExpectedJsonSchema: "true"); + + yield return new TestData(true); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(1.2f); + yield return new TestData(3.14159d); + yield return new TestData(3.14159M); +#if NET7_0_OR_GREATER + yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); +#endif +#if NET6_0_OR_GREATER + yield return new TestData((Half)3.141, ExpectedJsonSchema: """{"type":"number"}"""); +#endif + yield return new TestData("I am a string", ExpectedJsonSchema: """{"type":["string","null"]}"""); + yield return new TestData('c', ExpectedJsonSchema: """{"type":"string","minLength":1,"maxLength":1}"""); + yield return new TestData( + Value: [1, 2, 3], + AdditionalValues: [[]], + ExpectedJsonSchema: """{"type":["string","null"]}"""); + + yield return new TestData>(new byte[] { 1, 2, 3 }, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData>(new byte[] { 1, 2, 3 }, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData( + Value: new(2021, 1, 1), + AdditionalValues: [DateTime.MinValue, DateTime.MaxValue]); + + yield return new TestData( + Value: new(new DateTime(2021, 1, 1), TimeSpan.Zero), + AdditionalValues: [DateTimeOffset.MinValue, DateTimeOffset.MaxValue], + ExpectedJsonSchema: """{"type":"string","format": "date-time"}"""); + + yield return new TestData( + Value: new(hours: 5, minutes: 13, seconds: 3), + AdditionalValues: [TimeSpan.MinValue, TimeSpan.MaxValue], + ExpectedJsonSchema: """{"$comment": "Represents a System.TimeSpan value.", "type":"string", "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"}"""); + +#if NET6_0_OR_GREATER + yield return new TestData(new(2021, 1, 1), ExpectedJsonSchema: """{"type":"string","format": "date"}"""); + yield return new TestData(new(hour: 22, minute: 30, second: 33, millisecond: 100), ExpectedJsonSchema: """{"type":"string","format": "time"}"""); +#endif + yield return new TestData(Guid.Empty); + yield return new TestData(new("http://example.com"), ExpectedJsonSchema: """{"type":["string","null"], "format":"uri"}"""); + yield return new TestData(new(1, 2, 3, 4), ExpectedJsonSchema: """{"$comment":"Represents a version string.", "type":["string","null"],"pattern":"^\\d+(\\.\\d+){1,3}$"}"""); + yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]"""), ExpectedJsonSchema: "true"); + yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]""").RootElement, ExpectedJsonSchema: "true"); + yield return new TestData(JsonNode.Parse("""[{ "x" : 42 }]"""), ExpectedJsonSchema: "true"); + yield return new TestData((JsonValue)42, ExpectedJsonSchema: "true"); + yield return new TestData(new() { ["x"] = 42 }, ExpectedJsonSchema: """{"type":["object","null"]}"""); + yield return new TestData([1, 2, 3], ExpectedJsonSchema: """{"type":["array","null"]}"""); + + // Enum types + yield return new TestData(IntEnum.A, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(StringEnum.A, ExpectedJsonSchema: """{"enum": ["A","B","C"]}"""); + yield return new TestData(FlagsStringEnum.A, ExpectedJsonSchema: """{"type":"string"}"""); + + // Nullable types + yield return new TestData(true, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["boolean","null"]}"""); + yield return new TestData(42, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["integer","null"]}"""); + yield return new TestData(3.14, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["number","null"]}"""); + yield return new TestData(Guid.Empty, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["string","null"],"format":"uuid"}"""); + yield return new TestData(JsonDocument.Parse("{}").RootElement, AdditionalValues: [null], ExpectedJsonSchema: "true"); + yield return new TestData(IntEnum.A, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["integer","null"]}"""); + yield return new TestData(StringEnum.A, AdditionalValues: [null], ExpectedJsonSchema: """{"enum":["A","B","C",null]}"""); + yield return new TestData( + new(1, "two", true, 3.14), + AdditionalValues: [null], + ExpectedJsonSchema: """ + { + "type":["object","null"], + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + } + """); + + // User-defined POCOs + yield return new TestData( + Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, + AdditionalValues: [new() { String = "str", StringNullable = null }, null], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + } + } + """); + + // 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 }, + AdditionalValues: [new() { String = "str", StringNullable = null }], + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + } + } + """, + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); + + yield return new TestData( + Value: new(1, "two", true, 3.14), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X","Y","Z","W"] + } + """); + + yield return new TestData( + Value: new(1, "two", true, 3.14), + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + } + } + """); + + yield return new TestData( + Value: new(1, "two", true, 3.14, StringEnum.A), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X1": { "type": "integer" }, + "X2": { "type": "string" }, + "X3": { "type": "boolean" }, + "X4": { "type": "number" }, + "X5": { "enum": ["A", "B", "C"] }, + "Y1": { "type": "integer", "default": 42 }, + "Y2": { "type": "string", "default": "str" }, + "Y3": { "type": "boolean", "default": true }, + "Y4": { "type": "number", "default": 0 }, + "Y5": { "enum": ["A", "B", "C"], "default": "A" } + }, + "required": ["X1", "X2", "X3", "X4", "X5"] + } + """); + + yield return new TestData( + new() { X = "str1", Y = "str2" }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Y": { "type": "string" }, + "Z": { "type": "integer" }, + "X": { "type": "string" } + }, + "required": [ "Y", "Z", "X" ] + } + """); + + yield return new TestData( + new() { X = 1, Y = 2 }, + ExpectedJsonSchema: """ + { + "type": [ "object", "null" ], + "properties": { + "X": { "type": "integer" } + } + } + """); + yield return new TestData( + Value: new() { IntegerProperty = 1, StringProperty = "str" }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "int": { "type": "integer" }, + "str": { "type": [ "string", "null"] } + } + } + """); + + yield return new TestData( + Value: new() { X = 1 }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { "X": { "type": ["string","integer"], "pattern": "^-?(?:0|[1-9]\\d*)$" } } + } + """); + + yield return new TestData( + Value: new() { X = 1, Y = 2, Z = 3 }, + AdditionalValues: [ + new() { X = 1, Y = double.NaN, Z = 3 }, + new() { X = 1, Y = double.PositiveInfinity, Z = 3 }, + new() { X = 1, Y = double.NegativeInfinity, Z = 3 }, + ], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X": { "type": ["string", "integer"], "pattern": "^-?(?:0|[1-9]\\d*)$" }, + "Y": { + "anyOf": [ + { "type": "number" }, + { "enum": ["NaN", "Infinity", "-Infinity"]} + ] + }, + "Z": { "type": ["string", "integer"], "pattern": "^-?(?:0|[1-9]\\d*)$" }, + "W" : { "type": "number" } + } + } + """); + + yield return new TestData( + Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, + AdditionalValues: [null, new() { Value = 1, Next = null }], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": { "type": "integer" }, + "Next": { + "type": ["object","null"], + "properties": { + "Value": { "type": "integer" }, + "Next": { "$ref": "#/properties/Next" } + } + } + } + } + """); + + // 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 } } }, + AdditionalValues: [new() { Value = 1, Next = null }], + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "Value": { "type": "integer" }, + "Next": { + "type": ["object","null"], + "properties": { + "Value": { "type": "integer" }, + "Next": { "$ref": "#/properties/Next" } + } + } + } + } + """, + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); + +#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/108764 gets backported + SimpleRecord recordValue = new(42, "str", true, 3.14); + yield return new TestData( + Value: new() { Value1 = recordValue, Value2 = recordValue, ArrayValue = [recordValue], ListValue = [recordValue] }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value1": { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X", "Y", "Z", "W"] + }, + /* The same type on a different property is repeated to + account for potential metadata resolved from attributes. */ + "Value2": { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X", "Y", "Z", "W"] + }, + /* This collection element is the first occurrence + of the type without contextual metadata. */ + "ListValue": { + "type": ["array","null"], + "items": { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X", "Y", "Z", "W"] + } + }, + /* This collection element is the second occurrence + of the type which points to the first occurrence. */ + "ArrayValue": { + "type": ["array","null"], + "items": { + "$ref": "#/properties/ListValue/items" + } + } + } + } + """); +#endif + + yield return new TestData( + Value: new() { X = 42 }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X": { + "type": "integer" + } + } + } + """); + + yield return new TestData(new() { Value = 42 }, ExpectedJsonSchema: "true"); + yield return new TestData(new() { Value = 42 }, ExpectedJsonSchema: """{"type":["object","null"],"properties":{"Value":true}}"""); + yield return new TestData( + Value: new() + { + IntEnum = IntEnum.A, + StringEnum = StringEnum.B, + IntEnumUsingStringConverter = IntEnum.A, + NullableIntEnumUsingStringConverter = IntEnum.B, + StringEnumUsingIntConverter = StringEnum.A, + NullableStringEnumUsingIntConverter = StringEnum.B + }, + AdditionalValues: [ + new() + { + IntEnum = (IntEnum)int.MaxValue, + StringEnum = StringEnum.A, + IntEnumUsingStringConverter = IntEnum.A, + NullableIntEnumUsingStringConverter = null, + StringEnumUsingIntConverter = (StringEnum)int.MaxValue, + NullableStringEnumUsingIntConverter = null + }, + ], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "IntEnum": { "type": "integer" }, + "StringEnum": { "enum": [ "A", "B", "C" ] }, + "IntEnumUsingStringConverter": { "enum": [ "A", "B", "C" ] }, + "NullableIntEnumUsingStringConverter": { "enum": [ "A", "B", "C", null ] }, + "StringEnumUsingIntConverter": { "type": "integer" }, + "NullableStringEnumUsingIntConverter": { "type": [ "integer", "null" ] } + } + } + """); + + var recordStruct = new SimpleRecordStruct(42, "str", true, 3.14); + yield return new TestData( + Value: new() { Struct = recordStruct, NullableStruct = null }, + AdditionalValues: [new() { Struct = recordStruct, NullableStruct = recordStruct }], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Struct": { + "type": "object", + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + }, + "NullableStruct": { + "type": ["object","null"], + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + } + } + } + """); + + yield return new TestData( + Value: new() { NullableStruct = null, Struct = recordStruct }, + AdditionalValues: [new() { NullableStruct = recordStruct, Struct = recordStruct }], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "NullableStruct": { + "type": ["object","null"], + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + }, + "Struct": { + "type": "object", + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + } + } + } + """); + + yield return new TestData( + Value: new() { Name = "name", ExtensionData = new() { ["x"] = 42 } }, + ExpectedJsonSchema: """{"type":["object","null"],"properties":{"Name":{"type":["string","null"]}}}"""); + + yield return new TestData( + Value: new() { Name = "name", Age = 42 }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Name": {"type":["string","null"]}, + "Age": {"type":"integer"} + }, + "additionalProperties": false + } + """); + +#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/107545 gets backported + // Global JsonUnmappedMemberHandling.Disallow setting + yield return new TestData( + Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, + AdditionalValues: [new() { String = "str", StringNullable = null }, null], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + }, + "additionalProperties": false + } + """, + Options: new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }); +#endif + + yield return new TestData( + Value: new() { MaybeNull = null!, AllowNull = null, NotNull = null, DisallowNull = null!, NotNullDisallowNull = "str" }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "MaybeNull": {"type":["string","null"]}, + "AllowNull": {"type":["string","null"]}, + "NotNull": {"type":["string","null"]}, + "DisallowNull": {"type":["string","null"]}, + "NotNullDisallowNull": {"type":"string"} + } + } + """); + + yield return new TestData( + Value: new(allowNull: null, disallowNull: "str"), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "AllowNull": {"type":["string","null"]}, + "DisallowNull": {"type":"string"} + }, + "required": ["AllowNull", "DisallowNull"] + } + """); + + yield return new TestData( + Value: new(null), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": {"type":["string","null"]} + }, + "required": ["Value"] + } + """); + + yield return new TestData( + Value: new(), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X1": {"type":"string", "default": "str" }, + "X2": {"type":"integer", "default": 42 }, + "X3": {"type":"boolean", "default": true }, + "X4": {"type":"number", "default": 0 }, + "X5": {"enum":["A","B","C"], "default": "A" }, + "X6": {"type":["string","null"], "default": "str" }, + "X7": {"type":["integer","null"], "default": 42 }, + "X8": {"type":["boolean","null"], "default": true }, + "X9": {"type":["number","null"], "default": 0 }, + "X10": {"enum":["A","B","C", null], "default": "A" } + } + } + """); + + yield return new TestData>( + Value: new(null!), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": {"type":["string","null"]} + }, + "required": ["Value"] + } + """); + + yield return new TestData( + Value: new PocoWithPolymorphism.DerivedPocoStringDiscriminator { BaseValue = 42, DerivedValue = "derived" }, + AdditionalValues: [ + new PocoWithPolymorphism.DerivedPocoNoDiscriminator { BaseValue = 42, DerivedValue = "derived" }, + new PocoWithPolymorphism.DerivedPocoIntDiscriminator { BaseValue = 42, DerivedValue = "derived" }, + new PocoWithPolymorphism.DerivedCollection { BaseValue = 42 }, + new PocoWithPolymorphism.DerivedDictionary { BaseValue = 42 }, + ], + + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "anyOf": [ + { + "properties": { + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + } + }, + { + "properties": { + "$type": {"const":"derivedPoco"}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":42}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedCollection"}, + "$values": { + "type": "array", + "items": {"type":"integer"} + } + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedDictionary"} + }, + "additionalProperties":{"type": "integer"}, + "required": ["$type"] + } + ] + } + """); + + yield return new TestData( + Value: new NonAbstractClassWithSingleDerivedType(), + AdditionalValues: [new NonAbstractClassWithSingleDerivedType.Derived()], + ExpectedJsonSchema: """ + { + "type": ["object","null"] + } + """); + +#if !NET9_0 // Disable until https://github.com/microsoft/semantic-kernel/issues/8983 gets backported to .NET 9 + yield return new TestData( + Value: new(value: null), + AdditionalValues: [new(true), new(42), new(""), new(new object()), new(Array.Empty())], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": { "default": null } + } + } + """); +#endif + + yield return new TestData( + Value: new(), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "PolymorphicValue": { + "type": "object", + "anyOf": [ + { + "properties": { + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + } + }, + { + "properties": { + "$type": {"const":"derivedPoco"}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":42}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedCollection"}, + "$values": { + "type": "array", + "items": {"type":"integer"} + } + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedDictionary"} + }, + "additionalProperties":{"type": "integer"}, + "required": ["$type"] + } + ] + }, + "DerivedValue1": { + "type": "object", + "properties": { + "BaseValue": { + "type": "integer" + }, + "DerivedValue": { + "type": [ + "string", + "null" + ] + } + } + }, + "DerivedValue2": { + "type": "object", + "properties": { + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + } + } + } + } + """); + + yield return new TestData( + Value: new("string", -1), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "StringValue": {"type":"string","pattern":"\\w+"}, + "IntValue": {"type":"integer","default":42} + }, + "required": ["StringValue","IntValue"] + } + """, + ExporterOptions: new() + { + TransformSchemaNode = static (ctx, schema) => + { + if (ctx.PropertyInfo is null || schema is not JsonObject jObj) + { + return schema; + } + + if (ctx.ResolveAttribute() is { } attr) + { + jObj["default"] = JsonSerializer.SerializeToNode(attr.Value); + } + + if (ctx.ResolveAttribute() is { } regexAttr) + { + jObj["pattern"] = regexAttr.Pattern; + } + + return jObj; + } + }); + + // Collection types + yield return new TestData([1, 2, 3], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"integer"}}"""); + yield return new TestData>([false, true, false], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"boolean"}}"""); + yield return new TestData>(["one", "two", "three"], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":["string","null"]}}"""); + yield return new TestData>(new([1.1, 2.2, 3.3]), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"number"}}"""); + yield return new TestData>(new(['x', '2', '+']), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":1}}"""); + yield return new TestData>(ImmutableArray.Create(1, 2, 3), ExpectedJsonSchema: """{"type":"array","items":{"type":"integer"}}"""); + yield return new TestData>(ImmutableList.Create("one", "two", "three"), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":["string","null"]}}"""); + yield return new TestData>(ImmutableQueue.Create(false, false, true), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"boolean"}}"""); + yield return new TestData([1, "two", 3.14], ExpectedJsonSchema: """{"type":["array","null"]}"""); + yield return new TestData([1, "two", 3.14], ExpectedJsonSchema: """{"type":["array","null"]}"""); + + // Dictionary types + yield return new TestData>( + Value: new() { ["one"] = 1, ["two"] = 2, ["three"] = 3 }, + ExpectedJsonSchema: """{"type":["object","null"],"additionalProperties":{"type": "integer"}}"""); + + yield return new TestData>( + Value: new([new("one", 1), new("two", 2), new("three", 3)]), + ExpectedJsonSchema: """{"type":"object","additionalProperties":{"type": "integer"}}"""); + + yield return new TestData>( + Value: new() { [1] = "one", [2] = "two", [3] = "three" }, + ExpectedJsonSchema: """{"type":["object","null"],"additionalProperties":{"type": ["string","null"]}}"""); + + yield return new TestData>( + Value: new() + { + ["one"] = new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, + ["two"] = new() { String = "string", StringNullable = null, Int = 42, Double = 3.14, Boolean = true }, + ["three"] = new() { String = "string", StringNullable = null, Int = 42, Double = 3.14, Boolean = true }, + }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "additionalProperties": { + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + }, + "type": ["object","null"] + } + } + """); + + yield return new TestData>( + Value: new() { ["one"] = 1, ["two"] = "two", ["three"] = 3.14 }, + ExpectedJsonSchema: """{"type":["object","null"]}"""); + + yield return new TestData( + Value: new() { ["one"] = 1, ["two"] = "two", ["three"] = 3.14 }, + ExpectedJsonSchema: """{"type":["object","null"]}"""); + } + + public enum IntEnum { A, B, C } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum StringEnum { A, B, C } + + [Flags, JsonConverter(typeof(JsonStringEnumConverter))] + public enum FlagsStringEnum { A = 1, B = 2, C = 4 } + + public class SimplePoco + { + public string String { get; set; } = "default"; + public string? StringNullable { get; set; } + + public int Int { get; set; } + public double Double { get; set; } + public bool Boolean { get; set; } + } + + public record SimpleRecord(int X, string Y, bool Z, double W); + public record struct SimpleRecordStruct(int X, string Y, bool Z, double W); + + public record RecordWithOptionalParameters( + [property: Description("required integer")] int X1, string X2, bool X3, double X4, [Description("required string enum")] StringEnum X5, + [property: Description("optional integer")] int Y1 = 42, string Y2 = "str", bool Y3 = true, double Y4 = 0, [Description("optional string enum")] StringEnum Y5 = StringEnum.A); + + public class PocoWithRequiredMembers + { + [JsonInclude] + public required string X; + + public required string Y { get; set; } + + [JsonRequired] + public int Z { get; set; } + } + + public class PocoWithIgnoredMembers + { + public int X { get; set; } + + [JsonIgnore] + public int Y { get; set; } + } + + public class PocoWithCustomNaming + { + [JsonPropertyName("int")] + public int IntegerProperty { get; set; } + + [JsonPropertyName("str")] + public string? StringProperty { get; set; } + } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public class PocoWithCustomNumberHandling + { + public int X { get; set; } + } + + public class PocoWithCustomNumberHandlingOnProperties + { + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public int X { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowNamedFloatingPointLiterals)] + public double Y { get; set; } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + public int Z { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowNamedFloatingPointLiterals)] + public decimal W { get; set; } + } + + public class PocoWithRecursiveMembers + { + public int Value { get; init; } + public PocoWithRecursiveMembers? Next { get; init; } + } + + public class PocoWithNonRecursiveDuplicateOccurrences + { + public SimpleRecord? Value1 { get; set; } + public SimpleRecord? Value2 { get; set; } + public List? ListValue { get; set; } + public SimpleRecord[]? ArrayValue { get; set; } + } + + [Description("The type description")] + public class PocoWithDescription + { + [Description("The property description")] + public int X { get; set; } + } + + [JsonConverter(typeof(CustomConverter))] + public class PocoWithCustomConverter + { + public int Value { get; set; } + + public class CustomConverter : JsonConverter + { + public override PocoWithCustomConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new PocoWithCustomConverter { Value = reader.GetInt32() }; + + public override void Write(Utf8JsonWriter writer, PocoWithCustomConverter value, JsonSerializerOptions options) => + writer.WriteNumberValue(value.Value); + } + } + + public class PocoWithCustomPropertyConverter + { + [JsonConverter(typeof(CustomConverter))] + public int Value { get; set; } + + public class CustomConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => int.Parse(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); + } + } + + public class PocoWithEnums + { + public IntEnum IntEnum { get; init; } + public StringEnum StringEnum { get; init; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public IntEnum IntEnumUsingStringConverter { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public IntEnum? NullableIntEnumUsingStringConverter { get; set; } + + [JsonConverter(typeof(JsonNumberEnumConverter))] + public StringEnum StringEnumUsingIntConverter { get; set; } + + [JsonConverter(typeof(JsonNumberEnumConverter))] + public StringEnum? NullableStringEnumUsingIntConverter { get; set; } + } + + public class PocoWithStructFollowedByNullableStruct + { + public SimpleRecordStruct? NullableStruct { get; set; } + public SimpleRecordStruct Struct { get; set; } + } + + public class PocoWithNullableStructFollowedByStruct + { + public SimpleRecordStruct? NullableStruct { get; set; } + public SimpleRecordStruct Struct { get; set; } + } + + public class PocoWithExtensionDataProperty + { + public string? Name { get; set; } + + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + public class PocoDisallowingUnmappedMembers + { + public string? Name { get; set; } + public int Age { get; set; } + } + + public class PocoWithNullableAnnotationAttributes + { + [MaybeNull] + public string MaybeNull { get; set; } + + [AllowNull] + public string AllowNull { get; set; } + + [NotNull] + public string? NotNull { get; set; } + + [DisallowNull] + public string? DisallowNull { get; set; } + + [NotNull, DisallowNull] + public string? NotNullDisallowNull { get; set; } = ""; + } + + public class PocoWithNullableAnnotationAttributesOnConstructorParams([AllowNull] string allowNull, [DisallowNull] string? disallowNull) + { + public string AllowNull { get; } = allowNull!; + public string DisallowNull { get; } = disallowNull; + } + + public class PocoWithNullableConstructorParameter(string? value) + { + public string Value { get; } = value!; + } + + public class PocoWithOptionalConstructorParams( + string x1 = "str", int x2 = 42, bool x3 = true, double x4 = 0, StringEnum x5 = StringEnum.A, + string? x6 = "str", int? x7 = 42, bool? x8 = true, double? x9 = 0, StringEnum? x10 = StringEnum.A) + { + public string X1 { get; } = x1; + public int X2 { get; } = x2; + public bool X3 { get; } = x3; + public double X4 { get; } = x4; + public StringEnum X5 { get; } = x5; + + public string? X6 { get; } = x6; + public int? X7 { get; } = x7; + public bool? X8 { get; } = x8; + public double? X9 { get; } = x9; + public StringEnum? X10 { get; } = x10; + } + + // Regression test for https://github.com/dotnet/runtime/issues/92487 + public class GenericPocoWithNullableConstructorParameter(T value) + { + [NotNull] + public T Value { get; } = value!; + } + + [JsonDerivedType(typeof(DerivedPocoNoDiscriminator))] + [JsonDerivedType(typeof(DerivedPocoStringDiscriminator), "derivedPoco")] + [JsonDerivedType(typeof(DerivedPocoIntDiscriminator), 42)] + [JsonDerivedType(typeof(DerivedCollection), "derivedCollection")] + [JsonDerivedType(typeof(DerivedDictionary), "derivedDictionary")] + public abstract class PocoWithPolymorphism + { + public int BaseValue { get; set; } + + public class DerivedPocoNoDiscriminator : PocoWithPolymorphism + { + public string? DerivedValue { get; set; } + } + + public class DerivedPocoStringDiscriminator : PocoWithPolymorphism + { + public string? DerivedValue { get; set; } + } + + public class DerivedPocoIntDiscriminator : PocoWithPolymorphism + { + public string? DerivedValue { get; set; } + } + + public class DerivedCollection : PocoWithPolymorphism, IEnumerable + { + public IEnumerator GetEnumerator() => Enumerable.Repeat(BaseValue, 1).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public class DerivedDictionary : PocoWithPolymorphism, IReadOnlyDictionary + { + public int this[string key] => key == nameof(BaseValue) ? BaseValue : throw new KeyNotFoundException(); + public IEnumerable Keys => [nameof(BaseValue)]; + public IEnumerable Values => [BaseValue]; + public int Count => 1; + public bool ContainsKey(string key) => key == nameof(BaseValue); + public bool TryGetValue(string key, out int value) => key == nameof(BaseValue) ? (value = BaseValue) == BaseValue : (value = 0) == 0; + public IEnumerator> GetEnumerator() => Enumerable.Repeat(new KeyValuePair(nameof(BaseValue), BaseValue), 1).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + + [JsonDerivedType(typeof(NonAbstractClassWithSingleDerivedType.Derived))] + public class NonAbstractClassWithSingleDerivedType + { + public class Derived : NonAbstractClassWithSingleDerivedType; + } + + public class PocoCombiningPolymorphicTypeAndDerivedTypes + { + public PocoWithPolymorphism PolymorphicValue { get; set; } = new PocoWithPolymorphism.DerivedPocoNoDiscriminator { DerivedValue = "derived" }; + public PocoWithPolymorphism.DerivedPocoNoDiscriminator DerivedValue1 { get; set; } = new() { DerivedValue = "derived" }; + public PocoWithPolymorphism.DerivedPocoStringDiscriminator DerivedValue2 { get; set; } = new() { DerivedValue = "derived" }; + } + + public class ClassWithComponentModelAttributes + { + public ClassWithComponentModelAttributes(string stringValue, [DefaultValue(42)] int intValue) + { + StringValue = stringValue; + IntValue = intValue; + } + + [RegularExpression(@"\w+")] + public string StringValue { get; } + + public int IntValue { get; } + } + + public class ClassWithOptionalObjectParameter(object? value = null) + { + public object? Value { get; } = value; + } + + public readonly struct StructDictionary(IEnumerable> values) + : IReadOnlyDictionary + where TKey : notnull + { + private readonly IReadOnlyDictionary _dictionary = values.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + public TValue this[TKey key] => _dictionary[key]; + public IEnumerable Keys => _dictionary.Keys; + public IEnumerable Values => _dictionary.Values; + public int Count => _dictionary.Count; + public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key); + public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); +#if NETCOREAPP + 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); +#endif + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_dictionary).GetEnumerator(); + } + + [JsonSerializable(typeof(object))] + [JsonSerializable(typeof(bool))] + [JsonSerializable(typeof(byte))] + [JsonSerializable(typeof(ushort))] + [JsonSerializable(typeof(uint))] + [JsonSerializable(typeof(ulong))] + [JsonSerializable(typeof(sbyte))] + [JsonSerializable(typeof(short))] + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(long))] + [JsonSerializable(typeof(float))] + [JsonSerializable(typeof(double))] + [JsonSerializable(typeof(decimal))] +#if NET7_0_OR_GREATER + [JsonSerializable(typeof(UInt128))] + [JsonSerializable(typeof(Int128))] +#endif +#if NET6_0_OR_GREATER + [JsonSerializable(typeof(Half))] +#endif + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(char))] + [JsonSerializable(typeof(byte[]))] + [JsonSerializable(typeof(Memory))] + [JsonSerializable(typeof(ReadOnlyMemory))] + [JsonSerializable(typeof(DateTime))] + [JsonSerializable(typeof(DateTimeOffset))] + [JsonSerializable(typeof(TimeSpan))] +#if NET6_0_OR_GREATER + [JsonSerializable(typeof(DateOnly))] + [JsonSerializable(typeof(TimeOnly))] +#endif + [JsonSerializable(typeof(Guid))] + [JsonSerializable(typeof(Uri))] + [JsonSerializable(typeof(Version))] + [JsonSerializable(typeof(JsonDocument))] + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(JsonNode))] + [JsonSerializable(typeof(JsonValue))] + [JsonSerializable(typeof(JsonObject))] + [JsonSerializable(typeof(JsonArray))] + // Enum types + [JsonSerializable(typeof(IntEnum))] + [JsonSerializable(typeof(StringEnum))] + [JsonSerializable(typeof(FlagsStringEnum))] + // Nullable types + [JsonSerializable(typeof(bool?))] + [JsonSerializable(typeof(int?))] + [JsonSerializable(typeof(double?))] + [JsonSerializable(typeof(Guid?))] + [JsonSerializable(typeof(JsonElement?))] + [JsonSerializable(typeof(IntEnum?))] + [JsonSerializable(typeof(StringEnum?))] + [JsonSerializable(typeof(SimpleRecordStruct?))] + // User-defined POCOs + [JsonSerializable(typeof(SimplePoco))] + [JsonSerializable(typeof(SimpleRecord))] + [JsonSerializable(typeof(SimpleRecordStruct))] + [JsonSerializable(typeof(RecordWithOptionalParameters))] + [JsonSerializable(typeof(PocoWithRequiredMembers))] + [JsonSerializable(typeof(PocoWithIgnoredMembers))] + [JsonSerializable(typeof(PocoWithCustomNaming))] + [JsonSerializable(typeof(PocoWithCustomNumberHandling))] + [JsonSerializable(typeof(PocoWithCustomNumberHandlingOnProperties))] + [JsonSerializable(typeof(PocoWithRecursiveMembers))] + [JsonSerializable(typeof(PocoWithNonRecursiveDuplicateOccurrences))] + [JsonSerializable(typeof(PocoWithDescription))] + [JsonSerializable(typeof(PocoWithCustomConverter))] + [JsonSerializable(typeof(PocoWithCustomPropertyConverter))] + [JsonSerializable(typeof(PocoWithEnums))] + [JsonSerializable(typeof(PocoWithStructFollowedByNullableStruct))] + [JsonSerializable(typeof(PocoWithNullableStructFollowedByStruct))] + [JsonSerializable(typeof(PocoWithExtensionDataProperty))] + [JsonSerializable(typeof(PocoDisallowingUnmappedMembers))] + [JsonSerializable(typeof(PocoWithNullableAnnotationAttributes))] + [JsonSerializable(typeof(PocoWithNullableAnnotationAttributesOnConstructorParams))] + [JsonSerializable(typeof(PocoWithNullableConstructorParameter))] + [JsonSerializable(typeof(PocoWithOptionalConstructorParams))] + [JsonSerializable(typeof(GenericPocoWithNullableConstructorParameter))] + [JsonSerializable(typeof(PocoWithPolymorphism))] + [JsonSerializable(typeof(NonAbstractClassWithSingleDerivedType))] + [JsonSerializable(typeof(PocoCombiningPolymorphicTypeAndDerivedTypes))] + [JsonSerializable(typeof(ClassWithComponentModelAttributes))] + [JsonSerializable(typeof(ClassWithOptionalObjectParameter))] + // Collection types + [JsonSerializable(typeof(int[]))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(HashSet))] + [JsonSerializable(typeof(Queue))] + [JsonSerializable(typeof(Stack))] + [JsonSerializable(typeof(ImmutableArray))] + [JsonSerializable(typeof(ImmutableList))] + [JsonSerializable(typeof(ImmutableQueue))] + [JsonSerializable(typeof(object[]))] + [JsonSerializable(typeof(System.Collections.ArrayList))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(SortedDictionary))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(Hashtable))] + [JsonSerializable(typeof(StructDictionary))] + [JsonSerializable(typeof(XElement))] + public partial class TestTypesContext : JsonSerializerContext; + + 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 +#if NET9_0_OR_GREATER + GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? + GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? +#else + GetAttrs(ctx.PropertyAttributeProvider) ?? + GetAttrs(ctx.ParameterInfo) ?? +#endif + GetAttrs(ctx.TypeInfo.Type); + + static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => + (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); + } +} diff --git a/test/Shared/Shared.Tests.csproj b/test/Shared/Shared.Tests.csproj index d7bfa1801e2..dc2a46d60d9 100644 --- a/test/Shared/Shared.Tests.csproj +++ b/test/Shared/Shared.Tests.csproj @@ -5,16 +5,23 @@ - $(NoWarn);CA1716 + $(NoWarn);CA1716;S104 $(TestNetCoreTargetFrameworks) $(TestNetCoreTargetFrameworks)$(ConditionalNet462) + + true + true + + + + From 6ada76637c8381ebb6565892075ce816c76bdc4e Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 31 Oct 2024 17:20:53 +0000 Subject: [PATCH 076/190] Plug JsonSchemaExporter test data to the AIJsonUtilities tests (#5590) * Plug JsonSchemaExporter test data to the AIJsonUtilities tests * Update src/LegacySupport/DiagnosticAttributes/README.md * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs * Address feedback. --- eng/packages/TestOnly.props | 1 - .../Utilities/AIJsonUtilities.Schema.cs | 17 ++- ...ft.Extensions.AI.Abstractions.Tests.csproj | 13 +- .../{ => Utilities}/AIJsonUtilitiesTests.cs | 33 ++++- .../JsonSchemaExporterTests.cs | 6 +- .../{Helpers.cs => SchemaTestHelpers.cs} | 17 +-- test/Shared/JsonSchemaExporter/TestData.cs | 26 +++- test/Shared/JsonSchemaExporter/TestTypes.cs | 121 +++++++++--------- test/Shared/Shared.Tests.csproj | 2 +- 9 files changed, 142 insertions(+), 94 deletions(-) rename test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/{ => Utilities}/AIJsonUtilitiesTests.cs (79%) rename test/Shared/JsonSchemaExporter/{Helpers.cs => SchemaTestHelpers.cs} (75%) diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 78772d87d09..f6753c9c14d 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -21,7 +21,6 @@ - diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs index cd33a2557af..b555148df8b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs @@ -14,6 +14,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Schema; +using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; #pragma warning disable S1121 // Assignments should not be made from within sub-expressions @@ -282,7 +283,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) // 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. // STJ represents .NET integer types as ["string", "integer"], which will then lead to an error. - if (TypeIsArrayContainingInteger(objSchema)) + if (TypeIsIntegerWithStringNumberHandling(ctx, objSchema)) { // We don't want to emit any array for "type". In this case we know it contains "integer" // so reduce the type to that alone, assuming it's the most specific type. @@ -351,17 +352,21 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) } } - private static bool TypeIsArrayContainingInteger(JsonObject schema) + private static bool TypeIsIntegerWithStringNumberHandling(JsonSchemaExporterContext ctx, JsonObject schema) { - if (schema["type"] is JsonArray typeArray) + if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray typeArray) { - foreach (var entry in typeArray) + int count = 0; + foreach (JsonNode? entry in typeArray) { - if (entry?.GetValueKind() == JsonValueKind.String && entry.GetValue() == "integer") + if (entry?.GetValueKind() is JsonValueKind.String && + entry.GetValue() is "integer" or "string") { - return true; + count++; } } + + return count == typeArray.Count; } return false; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index 0d4d5fbfa96..911ce1b2bf8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -5,16 +5,27 @@ - $(NoWarn);CA1063;CA1861;CA2201;VSTHRD003 + $(NoWarn);CA1063;CA1861;CA2201;VSTHRD003;S104 true + true + true + true true + true + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs similarity index 79% rename from test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs rename to test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index d7ff5c6783e..52f9cad246d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -3,7 +3,9 @@ using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using Microsoft.Extensions.AI.JsonSchemaExporter; using Xunit; namespace Microsoft.Extensions.AI; @@ -130,7 +132,7 @@ public static void ResolveParameterJsonSchema_ReturnsExpectedValue() } [Fact] - public static void ResolveParameterJsonSchema_TreatsIntegralTypesAsInteger_EvenWithAllowReadingFromString() + public static void CreateParameterJsonSchema_TreatsIntegralTypesAsInteger_EvenWithAllowReadingFromString() { JsonElement expected = JsonDocument.Parse(""" { @@ -160,9 +162,36 @@ public enum MyEnumValue } [Fact] - public static void ResolveJsonSchema_CanBeBoolean() + public static void CreateJsonSchema_CanBeBoolean() { JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(object)); Assert.Equal(JsonValueKind.True, schema.ValueKind); } + + [Theory] + [MemberData(nameof(TestTypes.GetTestDataUsingAllValues), MemberType = typeof(TestTypes))] + public static void CreateJsonSchema_ValidateWithTestData(ITestData testData) + { + // Stress tests the schema generation method using types from the JsonSchemaExporter test battery. + + JsonSerializerOptions options = testData.Options is { } opts + ? new(opts) { TypeInfoResolver = TestTypes.TestTypesContext.Default } + : TestTypes.TestTypesContext.Default.Options; + + JsonElement schema = AIJsonUtilities.CreateJsonSchema(testData.Type, serializerOptions: options); + JsonNode? schemaAsNode = JsonSerializer.SerializeToNode(schema, options); + + Assert.NotNull(schemaAsNode); + Assert.Equal(testData.ExpectedJsonSchema.GetValueKind(), schemaAsNode.GetValueKind()); + + if (testData.Value is null || testData.WritesNumbersAsStrings) + { + // By design, our generated schema does not accept null root values + // or numbers formatted as strings, so we skip schema validation. + return; + } + + JsonNode? serializedValue = JsonSerializer.SerializeToNode(testData.Value, testData.Type, options); + SchemaTestHelpers.AssertDocumentMatchesSchema(schemaAsNode, serializedValue); + } } diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs index d526025d5ba..93207a7167f 100644 --- a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs @@ -32,7 +32,7 @@ public void TestTypes_GeneratesExpectedJsonSchema(ITestData testData) : Options; JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); - Helpers.AssertValidJsonSchema(testData.Type, testData.ExpectedJsonSchema, schema); + SchemaTestHelpers.AssertEqualJsonSchema(testData.ExpectedJsonSchema, schema); } [Theory] @@ -45,7 +45,7 @@ public void TestTypes_SerializedValueMatchesGeneratedSchema(ITestData testData) JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); JsonNode? instance = JsonSerializer.SerializeToNode(testData.Value, testData.Type, options); - Helpers.AssertDocumentMatchesSchema(schema, instance); + SchemaTestHelpers.AssertDocumentMatchesSchema(schema, instance); } [Theory] @@ -100,7 +100,7 @@ public void TypeWithDisallowUnmappedMembers_AdditionalPropertiesFailValidation() { JsonNode schema = Options.GetJsonSchemaAsNode(typeof(TestTypes.PocoDisallowingUnmappedMembers)); JsonNode? jsonWithUnmappedProperties = JsonNode.Parse("""{ "UnmappedProperty" : {} }"""); - Helpers.AssertDoesNotMatchSchema(schema, jsonWithUnmappedProperties); + SchemaTestHelpers.AssertDoesNotMatchSchema(schema, jsonWithUnmappedProperties); } [Fact] diff --git a/test/Shared/JsonSchemaExporter/Helpers.cs b/test/Shared/JsonSchemaExporter/SchemaTestHelpers.cs similarity index 75% rename from test/Shared/JsonSchemaExporter/Helpers.cs rename to test/Shared/JsonSchemaExporter/SchemaTestHelpers.cs index a925c1721f0..02e659a27aa 100644 --- a/test/Shared/JsonSchemaExporter/Helpers.cs +++ b/test/Shared/JsonSchemaExporter/SchemaTestHelpers.cs @@ -8,29 +8,20 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Json.Schema; -using Json.Schema.Generation; using Xunit.Sdk; namespace Microsoft.Extensions.AI.JsonSchemaExporter; -internal static partial class Helpers +internal static partial class SchemaTestHelpers { - public static void AssertValidJsonSchema(Type type, string? expectedJsonSchema, JsonNode actualJsonSchema) + public static void AssertEqualJsonSchema(JsonNode expectedJsonSchema, JsonNode actualJsonSchema) { - // If an expected schema is provided, use that. Otherwise, generate a schema from the type. - JsonNode? expectedJsonSchemaNode = expectedJsonSchema != null - ? JsonNode.Parse(expectedJsonSchema, documentOptions: new() { CommentHandling = JsonCommentHandling.Skip }) - : JsonSerializer.SerializeToNode(new JsonSchemaBuilder().FromType(type), Context.Default.JsonSchema); - - // Trim the $schema property from actual schema since it's not included by the generator. - (actualJsonSchema as JsonObject)?.Remove("$schema"); - - if (!JsonNode.DeepEquals(expectedJsonSchemaNode, actualJsonSchema)) + if (!JsonNode.DeepEquals(expectedJsonSchema, actualJsonSchema)) { throw new XunitException($""" Generated schema does not match the expected specification. Expected: - {FormatJson(expectedJsonSchemaNode)} + {FormatJson(expectedJsonSchema)} Actual: {FormatJson(actualJsonSchema)} """); diff --git a/test/Shared/JsonSchemaExporter/TestData.cs b/test/Shared/JsonSchemaExporter/TestData.cs index 6b2c9d841a3..0254a62b144 100644 --- a/test/Shared/JsonSchemaExporter/TestData.cs +++ b/test/Shared/JsonSchemaExporter/TestData.cs @@ -5,26 +5,40 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Schema; namespace Microsoft.Extensions.AI.JsonSchemaExporter; internal sealed record TestData( T? Value, + [StringSyntax(StringSyntaxAttribute.Json)] string ExpectedJsonSchema, IEnumerable? AdditionalValues = null, - [StringSyntax("Json")] string? ExpectedJsonSchema = null, JsonSchemaExporterOptions? ExporterOptions = null, - JsonSerializerOptions? Options = null) + JsonSerializerOptions? Options = null, + bool WritesNumbersAsStrings = false) : ITestData { + private static readonly JsonDocumentOptions _schemaParseOptions = new() { CommentHandling = JsonCommentHandling.Skip }; + public Type Type => typeof(T); object? ITestData.Value => Value; object? ITestData.ExporterOptions => ExporterOptions; + JsonNode ITestData.ExpectedJsonSchema { get; } = + JsonNode.Parse(ExpectedJsonSchema, documentOptions: _schemaParseOptions) + ?? throw new ArgumentNullException("schema must not be null"); IEnumerable ITestData.GetTestDataForAllValues() { yield return this; + if (default(T) is null && + ExporterOptions is { TreatNullObliviousAsNonNullable: false } && + Value is not null) + { + yield return this with { Value = default }; + } + if (AdditionalValues != null) { foreach (T? value in AdditionalValues) @@ -41,15 +55,13 @@ public interface ITestData object? Value { get; } - /// - /// Gets the expected JSON schema for the value. - /// Fall back to JsonSchemaGenerator as the source of truth if null. - /// - string? ExpectedJsonSchema { get; } + JsonNode ExpectedJsonSchema { get; } object? ExporterOptions { get; } JsonSerializerOptions? Options { get; } + bool WritesNumbersAsStrings { get; } + IEnumerable GetTestDataForAllValues(); } diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index 4615143aa78..f8c54fdb178 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -45,40 +45,41 @@ public static IEnumerable GetTestDataCore() // Primitives and built-in types yield return new TestData( Value: new(), - AdditionalValues: [null, 42, false, 3.14, 3.14M, new int[] { 1, 2, 3 }, new SimpleRecord(1, "str", false, 3.14)], + AdditionalValues: [42, false, 3.14, 3.14M, new int[] { 1, 2, 3 }, new SimpleRecord(1, "str", false, 3.14)], ExpectedJsonSchema: "true"); - yield return new TestData(true); - yield return new TestData(42); - yield return new TestData(42); - yield return new TestData(42); - yield return new TestData(42); - yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); - yield return new TestData(42); - yield return new TestData(42); - yield return new TestData(42); - yield return new TestData(1.2f); - yield return new TestData(3.14159d); - yield return new TestData(3.14159M); + yield return new TestData(true, """{"type":"boolean"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(1.2f, """{"type":"number"}"""); + yield return new TestData(3.14159d, """{"type":"number"}"""); + yield return new TestData(3.14159M, """{"type":"number"}"""); #if NET7_0_OR_GREATER - yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); - yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); #endif #if NET6_0_OR_GREATER - yield return new TestData((Half)3.141, ExpectedJsonSchema: """{"type":"number"}"""); + yield return new TestData((Half)3.141, """{"type":"number"}"""); #endif - yield return new TestData("I am a string", ExpectedJsonSchema: """{"type":["string","null"]}"""); - yield return new TestData('c', ExpectedJsonSchema: """{"type":"string","minLength":1,"maxLength":1}"""); + yield return new TestData("I am a string", """{"type":["string","null"]}"""); + yield return new TestData('c', """{"type":"string","minLength":1,"maxLength":1}"""); yield return new TestData( Value: [1, 2, 3], AdditionalValues: [[]], ExpectedJsonSchema: """{"type":["string","null"]}"""); - yield return new TestData>(new byte[] { 1, 2, 3 }, ExpectedJsonSchema: """{"type":"string"}"""); - yield return new TestData>(new byte[] { 1, 2, 3 }, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData>(new byte[] { 1, 2, 3 }, """{"type":"string"}"""); + yield return new TestData>(new byte[] { 1, 2, 3 }, """{"type":"string"}"""); yield return new TestData( Value: new(2021, 1, 1), - AdditionalValues: [DateTime.MinValue, DateTime.MaxValue]); + AdditionalValues: [DateTime.MinValue, DateTime.MaxValue], + ExpectedJsonSchema: """{"type":"string","format": "date-time"}"""); yield return new TestData( Value: new(new DateTime(2021, 1, 1), TimeSpan.Zero), @@ -91,35 +92,34 @@ public static IEnumerable GetTestDataCore() ExpectedJsonSchema: """{"$comment": "Represents a System.TimeSpan value.", "type":"string", "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"}"""); #if NET6_0_OR_GREATER - yield return new TestData(new(2021, 1, 1), ExpectedJsonSchema: """{"type":"string","format": "date"}"""); - yield return new TestData(new(hour: 22, minute: 30, second: 33, millisecond: 100), ExpectedJsonSchema: """{"type":"string","format": "time"}"""); + yield return new TestData(new(2021, 1, 1), """{"type":"string","format": "date"}"""); + yield return new TestData(new(hour: 22, minute: 30, second: 33, millisecond: 100), """{"type":"string","format": "time"}"""); #endif - yield return new TestData(Guid.Empty); - yield return new TestData(new("http://example.com"), ExpectedJsonSchema: """{"type":["string","null"], "format":"uri"}"""); - yield return new TestData(new(1, 2, 3, 4), ExpectedJsonSchema: """{"$comment":"Represents a version string.", "type":["string","null"],"pattern":"^\\d+(\\.\\d+){1,3}$"}"""); - yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]"""), ExpectedJsonSchema: "true"); - yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]""").RootElement, ExpectedJsonSchema: "true"); - yield return new TestData(JsonNode.Parse("""[{ "x" : 42 }]"""), ExpectedJsonSchema: "true"); - yield return new TestData((JsonValue)42, ExpectedJsonSchema: "true"); - yield return new TestData(new() { ["x"] = 42 }, ExpectedJsonSchema: """{"type":["object","null"]}"""); - yield return new TestData([1, 2, 3], ExpectedJsonSchema: """{"type":["array","null"]}"""); + yield return new TestData(Guid.Empty, """{"type":"string","format":"uuid"}"""); + yield return new TestData(new("http://example.com"), """{"type":["string","null"], "format":"uri"}"""); + yield return new TestData(new(1, 2, 3, 4), """{"$comment":"Represents a version string.", "type":["string","null"],"pattern":"^\\d+(\\.\\d+){1,3}$"}"""); + yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]"""), "true"); + yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]""").RootElement, "true"); + yield return new TestData(JsonNode.Parse("""[{ "x" : 42 }]"""), "true"); + yield return new TestData((JsonValue)42, "true"); + yield return new TestData(new() { ["x"] = 42 }, """{"type":["object","null"]}"""); + yield return new TestData([1, 2, 3], """{"type":["array","null"]}"""); // Enum types - yield return new TestData(IntEnum.A, ExpectedJsonSchema: """{"type":"integer"}"""); - yield return new TestData(StringEnum.A, ExpectedJsonSchema: """{"enum": ["A","B","C"]}"""); - yield return new TestData(FlagsStringEnum.A, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData(IntEnum.A, """{"type":"integer"}"""); + yield return new TestData(StringEnum.A, """{"enum": ["A","B","C"]}"""); + yield return new TestData(FlagsStringEnum.A, """{"type":"string"}"""); // Nullable types - yield return new TestData(true, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["boolean","null"]}"""); - yield return new TestData(42, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["integer","null"]}"""); - yield return new TestData(3.14, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["number","null"]}"""); - yield return new TestData(Guid.Empty, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["string","null"],"format":"uuid"}"""); - yield return new TestData(JsonDocument.Parse("{}").RootElement, AdditionalValues: [null], ExpectedJsonSchema: "true"); - yield return new TestData(IntEnum.A, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["integer","null"]}"""); - yield return new TestData(StringEnum.A, AdditionalValues: [null], ExpectedJsonSchema: """{"enum":["A","B","C",null]}"""); + yield return new TestData(true, """{"type":["boolean","null"]}"""); + yield return new TestData(42, """{"type":["integer","null"]}"""); + yield return new TestData(3.14, """{"type":["number","null"]}"""); + yield return new TestData(Guid.Empty, """{"type":["string","null"],"format":"uuid"}"""); + yield return new TestData(JsonDocument.Parse("{}").RootElement, "true"); + yield return new TestData(IntEnum.A, """{"type":["integer","null"]}"""); + yield return new TestData(StringEnum.A, """{"enum":["A","B","C",null]}"""); yield return new TestData( new(1, "two", true, 3.14), - AdditionalValues: [null], ExpectedJsonSchema: """ { "type":["object","null"], @@ -135,7 +135,7 @@ public static IEnumerable GetTestDataCore() // User-defined POCOs yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, - AdditionalValues: [new() { String = "str", StringNullable = null }, null], + AdditionalValues: [new() { String = "str", StringNullable = null }], ExpectedJsonSchema: """ { "type": ["object","null"], @@ -269,6 +269,7 @@ public static IEnumerable GetTestDataCore() new() { X = 1, Y = double.PositiveInfinity, Z = 3 }, new() { X = 1, Y = double.NegativeInfinity, Z = 3 }, ], + WritesNumbersAsStrings: true, ExpectedJsonSchema: """ { "type": ["object","null"], @@ -288,7 +289,7 @@ public static IEnumerable GetTestDataCore() yield return new TestData( Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, - AdditionalValues: [null, new() { Value = 1, Next = null }], + AdditionalValues: [new() { Value = 1, Next = null }], ExpectedJsonSchema: """ { "type": ["object","null"], @@ -397,8 +398,8 @@ of the type which points to the first occurrence. */ } """); - yield return new TestData(new() { Value = 42 }, ExpectedJsonSchema: "true"); - yield return new TestData(new() { Value = 42 }, ExpectedJsonSchema: """{"type":["object","null"],"properties":{"Value":true}}"""); + yield return new TestData(new() { Value = 42 }, "true"); + yield return new TestData(new() { Value = 42 }, """{"type":["object","null"],"properties":{"Value":true}}"""); yield return new TestData( Value: new() { @@ -495,7 +496,7 @@ of the type which points to the first occurrence. */ yield return new TestData( Value: new() { Name = "name", ExtensionData = new() { ["x"] = 42 } }, - ExpectedJsonSchema: """{"type":["object","null"],"properties":{"Name":{"type":["string","null"]}}}"""); + """{"type":["object","null"],"properties":{"Name":{"type":["string","null"]}}}"""); yield return new TestData( Value: new() { Name = "name", Age = 42 }, @@ -514,7 +515,7 @@ of the type which points to the first occurrence. */ // Global JsonUnmappedMemberHandling.Disallow setting yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, - AdditionalValues: [new() { String = "str", StringNullable = null }, null], + AdditionalValues: [new() { String = "str", StringNullable = null }], ExpectedJsonSchema: """ { "type": ["object","null"], @@ -793,16 +794,16 @@ of the type which points to the first occurrence. */ }); // Collection types - yield return new TestData([1, 2, 3], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"integer"}}"""); - yield return new TestData>([false, true, false], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"boolean"}}"""); - yield return new TestData>(["one", "two", "three"], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":["string","null"]}}"""); - yield return new TestData>(new([1.1, 2.2, 3.3]), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"number"}}"""); - yield return new TestData>(new(['x', '2', '+']), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":1}}"""); - yield return new TestData>(ImmutableArray.Create(1, 2, 3), ExpectedJsonSchema: """{"type":"array","items":{"type":"integer"}}"""); - yield return new TestData>(ImmutableList.Create("one", "two", "three"), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":["string","null"]}}"""); - yield return new TestData>(ImmutableQueue.Create(false, false, true), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"boolean"}}"""); - yield return new TestData([1, "two", 3.14], ExpectedJsonSchema: """{"type":["array","null"]}"""); - yield return new TestData([1, "two", 3.14], ExpectedJsonSchema: """{"type":["array","null"]}"""); + yield return new TestData([1, 2, 3], """{"type":["array","null"],"items":{"type":"integer"}}"""); + yield return new TestData>([false, true, false], """{"type":["array","null"],"items":{"type":"boolean"}}"""); + yield return new TestData>(["one", "two", "three"], """{"type":["array","null"],"items":{"type":["string","null"]}}"""); + yield return new TestData>(new([1.1, 2.2, 3.3]), """{"type":["array","null"],"items":{"type":"number"}}"""); + yield return new TestData>(new(['x', '2', '+']), """{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":1}}"""); + yield return new TestData>(ImmutableArray.Create(1, 2, 3), """{"type":"array","items":{"type":"integer"}}"""); + yield return new TestData>(ImmutableList.Create("one", "two", "three"), """{"type":["array","null"],"items":{"type":["string","null"]}}"""); + yield return new TestData>(ImmutableQueue.Create(false, false, true), """{"type":["array","null"],"items":{"type":"boolean"}}"""); + yield return new TestData([1, "two", 3.14], """{"type":["array","null"]}"""); + yield return new TestData([1, "two", 3.14], """{"type":["array","null"]}"""); // Dictionary types yield return new TestData>( @@ -1278,7 +1279,7 @@ public partial class TestTypesContext : JsonSerializerContext; // 2. Parameter-level attributes and // 3. Type-level attributes. return -#if NET9_0_OR_GREATER +#if NET9_0_OR_GREATER || !TESTS_JSON_SCHEMA_EXPORTER_POLYFILL GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? #else diff --git a/test/Shared/Shared.Tests.csproj b/test/Shared/Shared.Tests.csproj index dc2a46d60d9..456e50f67a9 100644 --- a/test/Shared/Shared.Tests.csproj +++ b/test/Shared/Shared.Tests.csproj @@ -2,6 +2,7 @@ Microsoft.Shared.Test Unit tests for Microsoft.Shared + $(DefineConstants);TESTS_JSON_SCHEMA_EXPORTER_POLYFILL @@ -22,6 +23,5 @@ - From 0672220635ac14edb061fce99f0419cf0b5526af Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 1 Nov 2024 11:20:36 +0000 Subject: [PATCH 077/190] Improve JsonSchemaExporter trimmer safety. (#5591) * Improve JsonSchemaExporter trimmer safety. * Remove var * Address feedback. * Remove DynamicallyAccessedMemberTypes.All * Extract reflection helpers into separate file and remove a number of warning suppressions. * Re-enable failing tests that were patched in .NET 9 --- .../JsonSchemaExporter.ReflectionHelpers.cs | 427 ++++++++++++++++++ .../JsonSchemaExporter/JsonSchemaExporter.cs | 421 ++--------------- src/Shared/Shared.csproj | 1 + .../JsonSchemaExporterTests.cs | 1 - test/Shared/JsonSchemaExporter/TestTypes.cs | 3 - 5 files changed, 475 insertions(+), 378 deletions(-) create mode 100644 src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs new file mode 100644 index 00000000000..481e5f75753 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs @@ -0,0 +1,427 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET9_0_OR_GREATER +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +#if !NET +using System.Linq; +#endif +using System.Reflection; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace System.Text.Json.Schema; + +internal static partial class JsonSchemaExporter +{ + private static class ReflectionHelpers + { + private const BindingFlags AllInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private static PropertyInfo? _jsonTypeInfo_ElementType; + private static PropertyInfo? _jsonPropertyInfo_MemberName; + private static FieldInfo? _nullableConverter_ElementConverter_Generic; + private static FieldInfo? _enumConverter_Options_Generic; + private static FieldInfo? _enumConverter_NamingPolicy_Generic; + + public static bool IsBuiltInConverter(JsonConverter converter) => + converter.GetType().Assembly == typeof(JsonConverter).Assembly; + + public static 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"); + + // Uses reflection to access the element type encapsulated by a JsonTypeInfo. + if (_jsonTypeInfo_ElementType is null) + { + PropertyInfo? elementTypeProperty = typeof(JsonTypeInfo).GetProperty("ElementType", AllInstance); + _jsonTypeInfo_ElementType = Throw.IfNull(elementTypeProperty); + } + + return (Type)_jsonTypeInfo_ElementType.GetValue(typeInfo)!; + } + + public static string? GetMemberName(JsonPropertyInfo propertyInfo) + { + // Uses reflection to the member name encapsulated by a JsonPropertyInfo. + if (_jsonPropertyInfo_MemberName is null) + { + PropertyInfo? memberName = typeof(JsonPropertyInfo).GetProperty("MemberName", AllInstance); + _jsonPropertyInfo_MemberName = Throw.IfNull(memberName); + } + + return (string?)_jsonPropertyInfo_MemberName.GetValue(propertyInfo); + } + + public static JsonConverter GetElementConverter(JsonConverter nullableConverter) + { + // Uses reflection to access the element converter encapsulated by a nullable converter. + if (_nullableConverter_ElementConverter_Generic is null) + { + FieldInfo? genericFieldInfo = Type + .GetType("System.Text.Json.Serialization.Converters.NullableConverter`1, System.Text.Json")! + .GetField("_elementConverter", AllInstance); + + _nullableConverter_ElementConverter_Generic = Throw.IfNull(genericFieldInfo); + } + + Type converterType = nullableConverter.GetType(); + var thisFieldInfo = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_nullableConverter_ElementConverter_Generic); + return (JsonConverter)thisFieldInfo.GetValue(nullableConverter)!; + } + + public static void GetEnumConverterConfig(JsonConverter enumConverter, out JsonNamingPolicy? namingPolicy, out bool allowString) + { + // Uses reflection to access configuration encapsulated by an enum converter. + if (_enumConverter_Options_Generic is null) + { + FieldInfo? genericFieldInfo = Type + .GetType("System.Text.Json.Serialization.Converters.EnumConverter`1, System.Text.Json")! + .GetField("_converterOptions", AllInstance); + + _enumConverter_Options_Generic = Throw.IfNull(genericFieldInfo); + } + + if (_enumConverter_NamingPolicy_Generic is null) + { + FieldInfo? genericFieldInfo = Type + .GetType("System.Text.Json.Serialization.Converters.EnumConverter`1, System.Text.Json")! + .GetField("_namingPolicy", AllInstance); + + _enumConverter_NamingPolicy_Generic = Throw.IfNull(genericFieldInfo); + } + + const int EnumConverterOptionsAllowStrings = 1; + Type converterType = enumConverter.GetType(); + var converterOptionsField = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_enumConverter_Options_Generic); + var namingPolicyField = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_enumConverter_NamingPolicy_Generic); + + namingPolicy = (JsonNamingPolicy?)namingPolicyField.GetValue(enumConverter); + int converterOptions = (int)converterOptionsField.GetValue(enumConverter)!; + allowString = (converterOptions & EnumConverterOptionsAllowStrings) != 0; + } + + // The .NET 8 source generator doesn't populate attribute providers for properties + // cf. https://github.com/dotnet/runtime/issues/100095 + // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property + // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 + public static ICustomAttributeProvider? ResolveAttributeProvider( + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties | + DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] + Type? declaringType, + JsonPropertyInfo? propertyInfo) + { + if (declaringType is null || propertyInfo is null) + { + return null; + } + + if (propertyInfo.AttributeProvider is { } provider) + { + return provider; + } + + string? memberName = ReflectionHelpers.GetMemberName(propertyInfo); + if (memberName is not null) + { + return (MemberInfo?)declaringType.GetProperty(memberName, AllInstance) ?? + declaringType.GetField(memberName, AllInstance); + } + + return null; + } + + // Resolves the parameters of the deserialization constructor for a type, if they exist. + public static Func? ResolveJsonConstructorParameterMapper( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] + Type type, + JsonTypeInfo typeInfo) + { + Debug.Assert(type == typeInfo.Type, "The declaring type must match the typeInfo type."); + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object, "Should only be passed object JSON kinds."); + + if (typeInfo.Properties.Count > 0 && + typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used + TryGetDeserializationConstructor(type, useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) + { + ParameterInfo[]? parameters = ctor?.GetParameters(); + if (parameters?.Length > 0) + { + Dictionary dict = new(parameters.Length); + foreach (ParameterInfo parameter in parameters) + { + if (parameter.Name is not null) + { + // We don't care about null parameter names or conflicts since they + // would have already been rejected by JsonTypeInfo exporterOptions. + dict[new(parameter.Name, parameter.ParameterType)] = parameter; + } + } + + return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; + } + } + + return null; + } + + // Resolves the nullable reference type annotations for a property or field, + // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. + public static NullabilityInfo GetMemberNullability(NullabilityInfoContext context, MemberInfo memberInfo) + { + Debug.Assert(memberInfo is PropertyInfo or FieldInfo, "Member must be property or field."); + return memberInfo is PropertyInfo prop + ? context.Create(prop) + : context.Create((FieldInfo)memberInfo); + } + + public static NullabilityState GetParameterNullability(NullabilityInfoContext context, ParameterInfo parameterInfo) + { +#if NET8_0 + // Workaround for https://github.com/dotnet/runtime/issues/92487 + // The fix has been incorporated into .NET 9 (and the polyfilled implementations in netfx). + // Should be removed once .NET 8 support is dropped. + if (GetGenericParameterDefinition(parameterInfo) is { ParameterType: { IsGenericParameter: true } typeParam }) + { + // Step 1. Look for nullable annotations on the type parameter. + if (GetNullableFlags(typeParam) is byte[] flags) + { + return TranslateByte(flags[0]); + } + + // Step 2. Look for nullable annotations on the generic method declaration. + if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) + { + return TranslateByte(flag); + } + + // Step 3. Look for nullable annotations on the generic method declaration. + if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) + { + return TranslateByte(flag2); + } + + // Default to nullable. + return NullabilityState.Nullable; + + static byte[]? GetNullableFlags(MemberInfo member) + { + foreach (CustomAttributeData attr in member.GetCustomAttributesData()) + { + Type attrType = attr.AttributeType; + if (attrType.Name == "NullableAttribute" && attrType.Namespace == "System.Runtime.CompilerServices") + { + foreach (CustomAttributeTypedArgument ctorArg in attr.ConstructorArguments) + { + switch (ctorArg.Value) + { + case byte flag: + return [flag]; + case byte[] flags: + return flags; + } + } + } + } + + return null; + } + + static byte? GetNullableContextFlag(MemberInfo member) + { + foreach (CustomAttributeData attr in member.GetCustomAttributesData()) + { + Type attrType = attr.AttributeType; + if (attrType.Name == "NullableContextAttribute" && attrType.Namespace == "System.Runtime.CompilerServices") + { + foreach (CustomAttributeTypedArgument ctorArg in attr.ConstructorArguments) + { + if (ctorArg.Value is byte flag) + { + return flag; + } + } + } + } + + return null; + } + +#pragma warning disable S109 // Magic numbers should not be used + static NullabilityState TranslateByte(byte b) => b switch + { + 1 => NullabilityState.NotNull, + 2 => NullabilityState.Nullable, + _ => NullabilityState.Unknown + }; +#pragma warning restore S109 // Magic numbers should not be used + } + + static ParameterInfo GetGenericParameterDefinition(ParameterInfo parameter) + { + if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } + or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) + { + var genericMethod = (MethodBase)GetGenericMemberDefinition(parameter.Member); + return genericMethod.GetParameters()[parameter.Position]; + } + + return parameter; + } + + static MemberInfo GetGenericMemberDefinition(MemberInfo member) + { + if (member is Type type) + { + return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; + } + + if (member.DeclaringType?.IsConstructedGenericType is true) + { + return member.DeclaringType.GetGenericTypeDefinition().GetMemberWithSameMetadataDefinitionAs(member); + } + + if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) + { + return method.GetGenericMethodDefinition(); + } + + return member; + } +#endif + return context.Create(parameterInfo).WriteState; + } + + // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 + public static object? GetNormalizedDefaultValue(ParameterInfo parameterInfo) + { + Type parameterType = parameterInfo.ParameterType; + object? defaultValue = parameterInfo.DefaultValue; + + if (defaultValue is null) + { + return null; + } + + // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. + if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) + { + return null; + } + + // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly + // cf. https://github.com/dotnet/runtime/issues/68647 + if (parameterType.IsEnum) + { + return Enum.ToObject(parameterType, defaultValue); + } + + if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) + { + return Enum.ToObject(underlyingType, defaultValue); + } + + return defaultValue; + } + + // Resolves the deserialization constructor for a type using logic copied from + // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 + private static bool TryGetDeserializationConstructor( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] + Type type, + bool useDefaultCtorInAnnotatedStructs, + out ConstructorInfo? deserializationCtor) + { + ConstructorInfo? ctorWithAttribute = null; + ConstructorInfo? publicParameterlessCtor = null; + ConstructorInfo? lonePublicCtor = null; + + ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + if (constructors.Length == 1) + { + lonePublicCtor = constructors[0]; + } + + foreach (ConstructorInfo constructor in constructors) + { + if (HasJsonConstructorAttribute(constructor)) + { + if (ctorWithAttribute != null) + { + deserializationCtor = null; + return false; + } + + ctorWithAttribute = constructor; + } + else if (constructor.GetParameters().Length == 0) + { + publicParameterlessCtor = constructor; + } + } + + // Search for non-public ctors with [JsonConstructor]. + foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (HasJsonConstructorAttribute(constructor)) + { + if (ctorWithAttribute != null) + { + deserializationCtor = null; + return false; + } + + ctorWithAttribute = constructor; + } + } + + // Structs will use default constructor if attribute isn't used. + if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) + { + deserializationCtor = null; + return true; + } + + deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; + return true; + + static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => + constructorInfo.GetCustomAttribute() != null; + } + + // Parameter to property matching semantics as declared in + // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 + private readonly struct ParameterLookupKey : IEquatable + { + public ParameterLookupKey(string name, Type type) + { + Name = name; + Type = type; + } + + public string Name { get; } + public Type Type { get; } + + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); + public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); + public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); + } + } + +#if !NET + private static MemberInfo GetMemberWithSameMetadataDefinitionAs(this Type specializedType, MemberInfo member) + { + const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + return specializedType.GetMember(member.Name, member.MemberType, All).First(m => m.MetadataToken == member.MetadataToken); + } +#endif +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs index 9c4b83f8343..5c6ce6d9ab7 100644 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs @@ -16,14 +16,9 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.Shared.Diagnostics; -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable LA0002 // Use 'Microsoft.Shared.Text.NumericExtensions.ToInvariantString' for improved performance #pragma warning disable S107 // Methods should not have too many parameters -#pragma warning disable S103 // Lines should not be too long #pragma warning disable S1121 // Assignments should not be made from within sub-expressions -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S3358 // Ternary operators should not be nested -#pragma warning disable EA0004 // Make type internal since project is executable namespace System.Text.Json.Schema; @@ -121,7 +116,7 @@ private static JsonSchema MapJsonSchemaCore( JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter; JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Options.NumberHandling; - if (!IsBuiltInConverter(effectiveConverter)) + if (!ReflectionHelpers.IsBuiltInConverter(effectiveConverter)) { // Return a `true` schema for types with user-defined converters. return CompleteSchema(ref state, JsonSchema.True); @@ -263,7 +258,8 @@ private static JsonSchema MapJsonSchemaCore( } } - Func? parameterInfoMapper = ResolveJsonConstructorParameterMapper(typeInfo); + Func? parameterInfoMapper = + ReflectionHelpers.ResolveJsonConstructorParameterMapper(typeInfo.Type, typeInfo); state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); foreach (JsonPropertyInfo property in typeInfo.Properties) @@ -277,13 +273,13 @@ private static JsonSchema MapJsonSchemaCore( JsonTypeInfo propertyTypeInfo = typeInfo.Options.GetTypeInfo(property.PropertyType); // Resolve the attribute provider for the property. - ICustomAttributeProvider? attributeProvider = ResolveAttributeProvider(typeInfo.Type, property); + ICustomAttributeProvider? attributeProvider = ReflectionHelpers.ResolveAttributeProvider(typeInfo.Type, property); // Declare the property as nullable if either getter or setter are nullable. bool isNonNullableProperty = false; if (attributeProvider is MemberInfo memberInfo) { - NullabilityInfo nullabilityInfo = state.NullabilityInfoContext.GetMemberNullability(memberInfo); + NullabilityInfo nullabilityInfo = ReflectionHelpers.GetMemberNullability(state.NullabilityInfoContext, memberInfo); isNonNullableProperty = (property.Get is null || nullabilityInfo.ReadState is NullabilityState.NotNull) && (property.Set is null || nullabilityInfo.WriteState is NullabilityState.NotNull); @@ -347,7 +343,7 @@ private static JsonSchema MapJsonSchemaCore( }); case JsonTypeInfoKind.Enumerable: - Type elementType = GetElementType(typeInfo); + Type elementType = ReflectionHelpers.GetElementType(typeInfo); JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementType); if (typeDiscriminator is null) @@ -398,7 +394,7 @@ private static JsonSchema MapJsonSchemaCore( } case JsonTypeInfoKind.Dictionary: - Type valueType = GetElementType(typeInfo); + Type valueType = ReflectionHelpers.GetElementType(typeInfo); JsonTypeInfo valueTypeInfo = typeInfo.Options.GetTypeInfo(valueType); List>? dictProps = null; @@ -449,17 +445,28 @@ JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) { if (schema.Ref is null) { - // 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. - bool isNullableSchema = (propertyInfo != null || parameterInfo != null) - ? !isNonNullableType - : CanBeNull(typeInfo.Type) && !parentPolymorphicTypeIsNonNullable && !state.ExporterOptions.TreatNullObliviousAsNonNullable; - - if (isNullableSchema) + if (IsNullableSchema(ref state)) { schema.MakeNullable(); } + + bool IsNullableSchema(ref GenerationState state) + { + // A schema is marked as nullable if either + // 1. We have a schema for a property where either the getter or setter are marked as nullable. + // 2. We have a schema for a reference type, unless we're explicitly treating null-oblivious types as non-nullable + + if (propertyInfo != null || parameterInfo != null) + { + return !isNonNullableType; + } + else + { + return ReflectionHelpers.CanBeNull(typeInfo.Type) && + !parentPolymorphicTypeIsNonNullable && + !state.ExporterOptions.TreatNullObliviousAsNonNullable; + } + } } if (state.ExporterOptions.TransformSchemaNode != null) @@ -636,11 +643,18 @@ private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, Jso if ((numberHandling & (JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)) != 0) { - pattern = schemaType is JsonSchemaType.Integer - ? @"^-?(?:0|[1-9]\d*)$" - : isIeeeFloatingPoint - ? @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$" - : @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; + if (schemaType is JsonSchemaType.Integer) + { + pattern = @"^-?(?:0|[1-9]\d*)$"; + } + else if (isIeeeFloatingPoint) + { + pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$"; + } + else + { + pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; + } schemaType |= JsonSchemaType.String; } @@ -660,62 +674,16 @@ private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, Jso return new JsonSchema { Type = schemaType, Pattern = pattern }; } - // Uses reflection to determine the element type of an enumerable or dictionary type - // Workaround for https://github.com/dotnet/runtime/issues/77306#issuecomment-2007887560 - private static Type GetElementType(JsonTypeInfo typeInfo) - { - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary, "TypeInfo must be of collection type"); - _elementTypeProperty ??= typeof(JsonTypeInfo).GetProperty("ElementType", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - return (Type)_elementTypeProperty?.GetValue(typeInfo)!; - } - - private static PropertyInfo? _elementTypeProperty; - - // The .NET 8 source generator doesn't populate attribute providers for properties - // cf. https://github.com/dotnet/runtime/issues/100095 - // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property - // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static ICustomAttributeProvider? ResolveAttributeProvider(Type? declaringType, JsonPropertyInfo? propertyInfo) - { - if (declaringType is null || propertyInfo is null) - { - return null; - } - - if (propertyInfo.AttributeProvider is { } provider) - { - return provider; - } - - _memberNameProperty ??= typeof(JsonPropertyInfo).GetProperty("MemberName", BindingFlags.Instance | BindingFlags.NonPublic)!; - var memberName = (string?)_memberNameProperty.GetValue(propertyInfo); - if (memberName is not null) - { - return declaringType.GetMember(memberName, MemberTypes.Property | MemberTypes.Field, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).FirstOrDefault(); - } - - return null; - } - - private static PropertyInfo? _memberNameProperty; - - // Uses reflection to determine any custom converters specified for the element of a nullable type. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] private static JsonConverter? ExtractCustomNullableConverter(JsonConverter? converter) { - Debug.Assert(converter is null || IsBuiltInConverter(converter), "If specified the converter must be built-in."); + Debug.Assert(converter is null || ReflectionHelpers.IsBuiltInConverter(converter), "If specified the converter must be built-in."); - // There is unfortunately no way in which we can obtain the element converter from a nullable converter without resorting to private reflection - // https://github.com/dotnet/runtime/blob/release/8.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/NullableConverter.cs#L15-L17 - Type? converterType = converter?.GetType(); - if (converterType?.Name == "NullableConverter`1") + if (converter is null) { - FieldInfo elementConverterField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_elementConverter"); - return (JsonConverter)elementConverterField!.GetValue(converter)!; + return null; } - return null; + return ReflectionHelpers.GetElementConverter(converter); } private static void ValidateOptions(JsonSerializerOptions options) @@ -740,12 +708,12 @@ private static void ResolveParameterInfo( Debug.Assert(parameterTypeInfo.Type == parameter.ParameterType, "The typeInfo type must match the ParameterInfo type."); // Incorporate the nullability information from the parameter. - isNonNullable = nullabilityInfoContext.GetParameterNullability(parameter) is NullabilityState.NotNull; + isNonNullable = ReflectionHelpers.GetParameterNullability(nullabilityInfoContext, parameter) is NullabilityState.NotNull; if (parameter.HasDefaultValue) { // Append the default value to the description. - object? defaultVal = parameter.GetNormalizedDefaultValue(); + object? defaultVal = ReflectionHelpers.GetNormalizedDefaultValue(parameter); defaultValue = JsonSerializer.SerializeToNode(defaultVal, parameterTypeInfo); hasDefaultValue = true; } @@ -758,25 +726,19 @@ private static void ResolveParameterInfo( } } - // Uses reflection to determine schema for enum types // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs#L498-L521 - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConverter converter) { - Debug.Assert(typeInfo.Type.IsEnum && IsBuiltInConverter(converter), "must be using a built-in enum converter."); + Debug.Assert(typeInfo.Type.IsEnum && ReflectionHelpers.IsBuiltInConverter(converter), "must be using a built-in enum converter."); if (converter is JsonConverterFactory factory) { converter = factory.CreateConverter(typeInfo.Type, typeInfo.Options)!; } - Type converterType = converter.GetType(); - FieldInfo converterOptionsField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_converterOptions"); - FieldInfo namingPolicyField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_namingPolicy"); + ReflectionHelpers.GetEnumConverterConfig(converter, out JsonNamingPolicy? namingPolicy, out bool allowString); - const int EnumConverterOptionsAllowStrings = 1; - var converterOptions = (int)converterOptionsField!.GetValue(converter)!; - if ((converterOptions & EnumConverterOptionsAllowStrings) != 0) + if (allowString) { // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity. @@ -787,7 +749,6 @@ private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConv return new() { Type = JsonSchemaType.String }; } - var namingPolicy = (JsonNamingPolicy?)namingPolicyField!.GetValue(converter)!; JsonArray enumValues = new(); foreach (string name in Enum.GetNames(typeInfo.Type)) { @@ -803,290 +764,6 @@ private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConv return new() { Type = JsonSchemaType.Integer }; } - private static NullabilityState GetParameterNullability(this NullabilityInfoContext context, ParameterInfo parameterInfo) - { -#if !NET9_0_OR_GREATER - // Workaround for https://github.com/dotnet/runtime/issues/92487 - if (GetGenericParameterDefinition(parameterInfo) is { ParameterType: { IsGenericParameter: true } typeParam }) - { - // Step 1. Look for nullable annotations on the type parameter. - if (GetNullableFlags(typeParam) is byte[] flags) - { - return TranslateByte(flags[0]); - } - - // Step 2. Look for nullable annotations on the generic method declaration. - if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) - { - return TranslateByte(flag); - } - - // Step 3. Look for nullable annotations on the generic method declaration. - if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) - { - return TranslateByte(flag2); - } - - // Default to nullable. - return NullabilityState.Nullable; - -#if NETCOREAPP - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", - Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] -#endif - static byte[]? GetNullableFlags(MemberInfo member) - { - Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => - { - Type attrType = attr.GetType(); - return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableAttribute"; - }); - - return (byte[])attr?.GetType().GetField("NullableFlags")?.GetValue(attr)!; - } - - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", - Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] - static byte? GetNullableContextFlag(MemberInfo member) - { - Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => - { - Type attrType = attr.GetType(); - return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableContextAttribute"; - }); - - return (byte?)attr?.GetType().GetField("Flag")?.GetValue(attr)!; - } - -#pragma warning disable S109 // Magic numbers should not be used - static NullabilityState TranslateByte(byte b) => b switch - { - 1 => NullabilityState.NotNull, - 2 => NullabilityState.Nullable, - _ => NullabilityState.Unknown - }; -#pragma warning restore S109 // Magic numbers should not be used - } - - static ParameterInfo GetGenericParameterDefinition(ParameterInfo parameter) - { - if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } - or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) - { - var genericMethod = (MethodBase)GetGenericMemberDefinition(parameter.Member); - return genericMethod.GetParameters()[parameter.Position]; - } - - return parameter; - } - - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", - Justification = "Looking up the generic member definition of the provided member.")] - static MemberInfo GetGenericMemberDefinition(MemberInfo member) - { - if (member is Type type) - { - return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; - } - - if (member.DeclaringType!.IsConstructedGenericType) - { - const BindingFlags AllMemberFlags = - BindingFlags.Static | BindingFlags.Instance | - BindingFlags.Public | BindingFlags.NonPublic; - - return member.DeclaringType.GetGenericTypeDefinition() - .GetMember(member.Name, AllMemberFlags) - .First(m => m.MetadataToken == member.MetadataToken); - } - - if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) - { - return method.GetGenericMethodDefinition(); - } - - return member; - } -#endif - return context.Create(parameterInfo).WriteState; - } - - // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 - private static object? GetNormalizedDefaultValue(this ParameterInfo parameterInfo) - { - Type parameterType = parameterInfo.ParameterType; - object? defaultValue = parameterInfo.DefaultValue; - - if (defaultValue is null) - { - return null; - } - - // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. - if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) - { - return null; - } - - // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly - // cf. https://github.com/dotnet/runtime/issues/68647 - if (parameterType.IsEnum) - { - return Enum.ToObject(parameterType, defaultValue); - } - - if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) - { - return Enum.ToObject(underlyingType, defaultValue); - } - - return defaultValue; - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static FieldInfo GetPrivateFieldWithPotentiallyTrimmedMetadata(this Type type, string fieldName) - { - FieldInfo? field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); - if (field is null) - { - throw new InvalidOperationException( - $"Could not resolve metadata for field '{fieldName}' in type '{type}'. " + - "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."); - } - - return field; - } - - // Resolves the parameters of the deserialization constructor for a type, if they exist. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static Func? ResolveJsonConstructorParameterMapper(JsonTypeInfo typeInfo) - { - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object, "Should only be passed object JSON kinds."); - - if (typeInfo.Properties.Count > 0 && - typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used - typeInfo.Type.TryGetDeserializationConstructor(useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) - { - ParameterInfo[]? parameters = ctor?.GetParameters(); - if (parameters?.Length > 0) - { - Dictionary dict = new(parameters.Length); - foreach (ParameterInfo parameter in parameters) - { - if (parameter.Name is not null) - { - // We don't care about null parameter names or conflicts since they - // would have already been rejected by JsonTypeInfo exporterOptions. - dict[new(parameter.Name, parameter.ParameterType)] = parameter; - } - } - - return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; - } - } - - return null; - } - - // Parameter to property matching semantics as declared in - // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 - private readonly struct ParameterLookupKey : IEquatable - { - public ParameterLookupKey(string name, Type type) - { - Name = name; - Type = type; - } - - public string Name { get; } - public Type Type { get; } - - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); - public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); - public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); - } - - // Resolves the deserialization constructor for a type using logic copied from - // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static bool TryGetDeserializationConstructor( - this Type type, - bool useDefaultCtorInAnnotatedStructs, - out ConstructorInfo? deserializationCtor) - { - ConstructorInfo? ctorWithAttribute = null; - ConstructorInfo? publicParameterlessCtor = null; - ConstructorInfo? lonePublicCtor = null; - - ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); - - if (constructors.Length == 1) - { - lonePublicCtor = constructors[0]; - } - - foreach (ConstructorInfo constructor in constructors) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - else if (constructor.GetParameters().Length == 0) - { - publicParameterlessCtor = constructor; - } - } - - // Search for non-public ctors with [JsonConstructor]. - foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - } - - // Structs will use default constructor if attribute isn't used. - if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) - { - deserializationCtor = null; - return true; - } - - deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; - return true; - - static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => - constructorInfo.GetCustomAttribute() != null; - } - - private static bool IsBuiltInConverter(JsonConverter converter) => - converter.GetType().Assembly == typeof(JsonConverter).Assembly; - - // Resolves the nullable reference type annotations for a property or field, - // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. - private static NullabilityInfo GetMemberNullability(this NullabilityInfoContext context, MemberInfo memberInfo) - { - Debug.Assert(memberInfo is PropertyInfo or FieldInfo, "Member must be property or field."); - return memberInfo is PropertyInfo prop - ? context.Create(prop) - : context.Create((FieldInfo)memberInfo); - } - - private static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; - private static class JsonSchemaConstants { public const string SchemaPropertyName = "$schema"; @@ -1116,10 +793,6 @@ private static class ThrowHelpers public static void ThrowInvalidOperationException_MaxDepthReached() => throw new InvalidOperationException("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting."); - [DoesNotReturn] - public static void ThrowInvalidOperationException_TrimmedMethodParameters(MethodBase method) => - throw new InvalidOperationException($"The parameters for method '{method}' have been trimmed away."); - [DoesNotReturn] public static void ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported() => throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); diff --git a/src/Shared/Shared.csproj b/src/Shared/Shared.csproj index 58ec4eda535..439c3788557 100644 --- a/src/Shared/Shared.csproj +++ b/src/Shared/Shared.csproj @@ -17,6 +17,7 @@ true true true + true diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs index 93207a7167f..2ec81987dc2 100644 --- a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs @@ -15,7 +15,6 @@ using Xunit; #pragma warning disable SA1402 // File may only contain a single type -#pragma warning disable xUnit1000 // Test classes must be public namespace Microsoft.Extensions.AI.JsonSchemaExporter; diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index f8c54fdb178..d21a40640dd 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -27,7 +27,6 @@ #pragma warning disable CA1052 // Static holder types should be Static or NotInheritable #pragma warning disable S1121 // Assignments should not be made from within sub-expressions #pragma warning disable IDE0073 // The file header is missing or not located at the top of the file -#pragma warning disable SA1402 // File may only contain a single type namespace Microsoft.Extensions.AI.JsonSchemaExporter; @@ -511,7 +510,6 @@ of the type which points to the first occurrence. */ } """); -#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/107545 gets backported // Global JsonUnmappedMemberHandling.Disallow setting yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -530,7 +528,6 @@ of the type which points to the first occurrence. */ } """, Options: new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }); -#endif yield return new TestData( Value: new() { MaybeNull = null!, AllowNull = null, NotNull = null, DisallowNull = null!, NotNullDisallowNull = "str" }, From 53783e75e2bcd1807efd5b9550bbda69d072bb12 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 1 Nov 2024 09:38:21 -0400 Subject: [PATCH 078/190] Improve AdditionalPropertiesDictionary (#5593) - Add a strongly-typed Enumerator - Add a TryAdd method - Add a DebuggerDisplay for Count - Add a DebuggerTypeProxy for the collection of properties --- .../AdditionalPropertiesDictionary.cs | 92 ++++++++++++++++++- .../AdditionalPropertiesDictionaryTests.cs | 41 +++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs index 616ad284198..4a681d4679a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs @@ -4,13 +4,21 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S1144 // Unused private types or members should be removed +#pragma warning disable S2365 // Properties should not make collection or array copies +#pragma warning disable S3604 // Member initializer values should not be redundant namespace Microsoft.Extensions.AI; /// Provides a dictionary used as the AdditionalProperties dictionary on Microsoft.Extensions.AI objects. +[DebuggerTypeProxy(typeof(DebugView))] +[DebuggerDisplay("Count = {Count}")] public sealed class AdditionalPropertiesDictionary : IDictionary, IReadOnlyDictionary { /// The underlying dictionary. @@ -77,6 +85,25 @@ public object? this[string key] /// public void Add(string key, object? value) => _dictionary.Add(key, value); + /// Attempts to add the specified key and value to the dictionary. + /// The key of the element to add. + /// The value of the element to add. + /// if the key/value pair was added to the dictionary successfully; otherwise, . + public bool TryAdd(string key, object? value) + { +#if NET + return _dictionary.TryAdd(key, value); +#else + if (!_dictionary.ContainsKey(key)) + { + _dictionary.Add(key, value); + return true; + } + + return false; +#endif + } + /// void ICollection>.Add(KeyValuePair item) => ((ICollection>)_dictionary).Add(item); @@ -93,11 +120,17 @@ public object? this[string key] void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) => ((ICollection>)_dictionary).CopyTo(array, arrayIndex); + /// + /// Returns an enumerator that iterates through the . + /// + /// An that enumerates the contents of the . + public Enumerator GetEnumerator() => new(_dictionary.GetEnumerator()); + /// - public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); + IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); /// - IEnumerator IEnumerable.GetEnumerator() => _dictionary.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// public bool Remove(string key) => _dictionary.Remove(key); @@ -156,4 +189,59 @@ public bool TryGetValue(string key, [NotNullWhen(true)] out T? value) value = default; return false; } + + /// Enumerates the elements of an . + public struct Enumerator : IEnumerator> + { + /// The wrapped dictionary enumerator. + private Dictionary.Enumerator _dictionaryEnumerator; + + /// Initializes a new instance of the struct with the dictionary enumerator to wrap. + /// The dictionary enumerator to wrap. + internal Enumerator(Dictionary.Enumerator dictionaryEnumerator) + { + _dictionaryEnumerator = dictionaryEnumerator; + } + + /// + public KeyValuePair Current => _dictionaryEnumerator.Current; + + /// + object IEnumerator.Current => Current; + + /// + public void Dispose() => _dictionaryEnumerator.Dispose(); + + /// + public bool MoveNext() => _dictionaryEnumerator.MoveNext(); + + /// + public void Reset() => Reset(ref _dictionaryEnumerator); + + /// Calls on an enumerator. + private static void Reset(ref TEnumerator enumerator) + where TEnumerator : struct, IEnumerator + { + enumerator.Reset(); + } + } + + /// Provides a debugger view for the collection. + private sealed class DebugView(AdditionalPropertiesDictionary properties) + { + private readonly AdditionalPropertiesDictionary _properties = Throw.IfNull(properties); + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public AdditionalProperty[] Items => (from p in _properties select new AdditionalProperty(p.Key, p.Value)).ToArray(); + + [DebuggerDisplay("{Value}", Name = "[{Key}]")] + public readonly struct AdditionalProperty(string key, object? value) + { + [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] + public string Key { get; } = key; + + [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] + public object? Value { get; } = value; + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs index a9a544c8ca8..09f515fa066 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs @@ -90,4 +90,45 @@ static void AssertNotFound(T1 input) Assert.Equal(default(T2), value); } } + + [Fact] + public void TryAdd_AddsOnlyIfNonExistent() + { + AdditionalPropertiesDictionary d = []; + + Assert.False(d.ContainsKey("key")); + Assert.True(d.TryAdd("key", "value")); + Assert.True(d.ContainsKey("key")); + Assert.Equal("value", d["key"]); + + Assert.False(d.TryAdd("key", "value2")); + Assert.True(d.ContainsKey("key")); + Assert.Equal("value", d["key"]); + } + + [Fact] + public void Enumerator_EnumeratesAllItems() + { + AdditionalPropertiesDictionary d = []; + + const int NumProperties = 10; + for (int i = 0; i < NumProperties; i++) + { + d.Add($"key{i}", $"value{i}"); + } + + Assert.Equal(NumProperties, d.Count); + + // This depends on an implementation detail of the ordering in which the dictionary + // enumerates items. If that ever changes, this test will need to be updated. + int count = 0; + foreach (KeyValuePair item in d) + { + Assert.Equal($"key{count}", item.Key); + Assert.Equal($"value{count}", item.Value); + count++; + } + + Assert.Equal(NumProperties, count); + } } From a12664ed4aa725d414ad824bee800bbca2e3d121 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 1 Nov 2024 10:19:52 -0400 Subject: [PATCH 079/190] Add UseEmbeddingGenerationOptions (#5594) * Add UseEmbeddingGenerationOptions Counterpart to UseChatOptions * Document/test null options returned from callback --- .../ConfigureOptionsChatClient.cs | 9 ++- ...igureOptionsChatClientBuilderExtensions.cs | 9 ++- .../ConfigureOptionsEmbeddingGenerator.cs | 75 +++++++++++++++++++ ...ionsEmbeddingGeneratorBuilderExtensions.cs | 56 ++++++++++++++ .../ConfigureOptionsChatClientTests.cs | 8 +- ...ConfigureOptionsEmbeddingGeneratorTests.cs | 58 ++++++++++++++ 6 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs index 895bf8873df..990c92d3ad9 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs @@ -17,7 +17,7 @@ namespace Microsoft.Extensions.AI; /// /// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options /// with a new instance, the callback may simply return that new instance, for example _ => new ChatOptions() { MaxTokens = 1000 }. To provide -/// a new instance only if the caller-supplied instance is `null`, the callback may conditionally return a new instance, for example +/// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example /// options => options ?? new ChatOptions() { MaxTokens = 1000 }. Any changes to the caller-provided options instance will persist on the /// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance /// and mutating the clone, for example: @@ -31,6 +31,9 @@ namespace Microsoft.Extensions.AI; /// /// /// +/// The callback may return , in which case a options will be passed to the next client in the pipeline. +/// +/// /// The provided implementation of is thread-safe for concurrent use so long as the employed configuration /// callback is also thread-safe for concurrent requests. If callers employ a shared options instance, care should be taken in the /// configuration callback, as multiple calls to it may end up running in parallel with the same options instance. @@ -39,7 +42,7 @@ namespace Microsoft.Extensions.AI; public sealed class ConfigureOptionsChatClient : DelegatingChatClient { /// The callback delegate used to configure options. - private readonly Func _configureOptions; + private readonly Func _configureOptions; /// Initializes a new instance of the class with the specified callback. /// The inner client. @@ -47,7 +50,7 @@ public sealed class ConfigureOptionsChatClient : DelegatingChatClient /// The delegate to invoke to configure the instance. It is passed the caller-supplied /// instance and should return the configured instance to use. /// - public ConfigureOptionsChatClient(IChatClient innerClient, Func configureOptions) + public ConfigureOptionsChatClient(IChatClient innerClient, Func configureOptions) : base(innerClient) { _configureOptions = Throw.IfNull(configureOptions); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs index 12b903c0dac..2d98fbd9003 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs @@ -21,9 +21,10 @@ public static class ConfigureOptionsChatClientBuilderExtensions /// /// The . /// + /// /// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options /// with a new instance, the callback may simply return that new instance, for example _ => new ChatOptions() { MaxTokens = 1000 }. To provide - /// a new instance only if the caller-supplied instance is `null`, the callback may conditionally return a new instance, for example + /// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example /// options => options ?? new ChatOptions() { MaxTokens = 1000 }. Any changes to the caller-provided options instance will persist on the /// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance /// and mutating the clone, for example: @@ -35,9 +36,13 @@ public static class ConfigureOptionsChatClientBuilderExtensions /// return newOptions; /// } /// + /// + /// + /// The callback may return , in which case a options will be passed to the next client in the pipeline. + /// /// public static ChatClientBuilder UseChatOptions( - this ChatClientBuilder builder, Func configureOptions) + this ChatClientBuilder builder, Func configureOptions) { _ = Throw.IfNull(builder); _ = Throw.IfNull(configureOptions); diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs new file mode 100644 index 00000000000..9068ac41caa --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable SA1629 // Documentation text should end with a period + +namespace Microsoft.Extensions.AI; + +/// A delegating embedding generator that updates or replaces the used by the remainder of the pipeline. +/// Specifies the type of the input passed to the generator. +/// Specifies the type of the embedding instance produced by the generator. +/// +/// +/// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options +/// with a new instance, the callback may simply return that new instance, for example _ => new EmbeddingGenerationOptions() { Dimensions = 100 }. To provide +/// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example +/// options => options ?? new EmbeddingGenerationOptions() { Dimensions = 100 }. Any changes to the caller-provided options instance will persist on the +/// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance +/// and mutating the clone, for example: +/// +/// options => +/// { +/// var newOptions = options?.Clone() ?? new(); +/// newOptions.Dimensions = 100; +/// return newOptions; +/// } +/// +/// +/// +/// The callback may return , in which case a options will be passed to the next generator in the pipeline. +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the employed configuration +/// callback is also thread-safe for concurrent requests. If callers employ a shared options instance, care should be taken in the +/// configuration callback, as multiple calls to it may end up running in parallel with the same options instance. +/// +/// +public sealed class ConfigureOptionsEmbeddingGenerator : DelegatingEmbeddingGenerator + where TEmbedding : Embedding +{ + /// The callback delegate used to configure options. + private readonly Func _configureOptions; + + /// + /// Initializes a new instance of the class with the + /// specified callback. + /// + /// The inner generator. + /// + /// The delegate to invoke to configure the instance. It is passed the caller-supplied + /// instance and should return the configured instance to use. + /// + public ConfigureOptionsEmbeddingGenerator( + IEmbeddingGenerator innerGenerator, + Func configureOptions) + : base(innerGenerator) + { + _configureOptions = Throw.IfNull(configureOptions); + } + + /// + public override async Task> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + return await base.GenerateAsync(values, _configureOptions(options), cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..011f4c058e9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable SA1629 // Documentation text should end with a period + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +public static class ConfigureOptionsEmbeddingGeneratorBuilderExtensions +{ + /// + /// Adds a callback that updates or replaces . This can be used to set default options. + /// + /// Specifies the type of the input passed to the generator. + /// Specifies the type of the embedding instance produced by the generator. + /// The . + /// + /// The delegate to invoke to configure the instance. It is passed the caller-supplied + /// instance and should return the configured instance to use. + /// + /// The . + /// + /// + /// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options + /// with a new instance, the callback may simply return that new instance, for example _ => new EmbeddingGenerationOptions() { Dimensions = 100 }. To provide + /// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example + /// options => options ?? new EmbeddingGenerationOptions() { Dimensions = 100 }. Any changes to the caller-provided options instance will persist on the + /// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance + /// and mutating the clone, for example: + /// + /// options => + /// { + /// var newOptions = options?.Clone() ?? new(); + /// newOptions.Dimensions = 100; + /// return newOptions; + /// } + /// + /// + /// + /// The callback may return , in which case a options will be passed to the next generator in the pipeline. + /// + /// + public static EmbeddingGeneratorBuilder UseEmbeddingGenerationOptions( + this EmbeddingGeneratorBuilder builder, + Func configureOptions) + where TEmbedding : Embedding + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(configureOptions); + + return builder.Use(innerGenerator => new ConfigureOptionsEmbeddingGenerator(innerGenerator, configureOptions)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs index a27761c99ec..a911340813f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs @@ -26,11 +26,13 @@ public void UseChatOptions_InvalidArgs_Throws() Assert.Throws("configureOptions", () => builder.UseChatOptions(null!)); } - [Fact] - public async Task ConfigureOptions_ReturnedInstancePassedToNextClient() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullReturned) { ChatOptions providedOptions = new(); - ChatOptions returnedOptions = new(); + ChatOptions? returnedOptions = nullReturned ? null : new(); ChatCompletion expectedCompletion = new(Array.Empty()); var expectedUpdates = Enumerable.Range(0, 3).Select(i => new StreamingChatCompletionUpdate()).ToArray(); using CancellationTokenSource cts = new(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs new file mode 100644 index 00000000000..b8a4b82cb59 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ConfigureOptionsEmbeddingGeneratorTests +{ + [Fact] + public void ConfigureOptionsEmbeddingGenerator_InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new ConfigureOptionsEmbeddingGenerator>(null!, _ => new EmbeddingGenerationOptions())); + Assert.Throws("configureOptions", () => new ConfigureOptionsEmbeddingGenerator>(new TestEmbeddingGenerator(), null!)); + } + + [Fact] + public void UseEmbeddingGenerationOptions_InvalidArgs_Throws() + { + var builder = new EmbeddingGeneratorBuilder>(); + Assert.Throws("configureOptions", () => builder.UseEmbeddingGenerationOptions(null!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullReturned) + { + EmbeddingGenerationOptions providedOptions = new(); + EmbeddingGenerationOptions? returnedOptions = nullReturned ? null : new(); + GeneratedEmbeddings> expectedEmbeddings = []; + using CancellationTokenSource cts = new(); + + using IEmbeddingGenerator> innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = (inputs, options, cancellationToken) => + { + Assert.Same(returnedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return Task.FromResult(expectedEmbeddings); + } + }; + + using var generator = new EmbeddingGeneratorBuilder>() + .UseEmbeddingGenerationOptions(options => + { + Assert.Same(providedOptions, options); + return returnedOptions; + }) + .Use(innerGenerator); + + var embeddings = await generator.GenerateAsync([], providedOptions, cts.Token); + Assert.Same(expectedEmbeddings, embeddings); + } +} From 9eea77d211b201c6e60c5f5074238cca08ac1ba9 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 4 Nov 2024 11:45:20 +0000 Subject: [PATCH 080/190] HybridCache stability and logging improvements (#5467) * - handle serialization failures - enforce payload quota - enforce key validity - add proper logging (infrastructure failure: needs attn) # Conflicts: # src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj * - add "callback" to .dic - log deserialization failures - expose serialization failures - tests for serialization logging scenarios * support and tests for stability despite unreliable L2 * nit * Compile for NS2.0 * include enabled check in our log output * add event-source tracing and counters * explicitly specify event-source guid * satisfy the stylebot overloads * nix SDT * fix failing CI test * limit to net462 * PR feedback (all except event tests) * naming * add event source tests * fix redundant comment * add clarification * more clarifications * dance for our robot overlords * drop Microsoft.Extensions.Telemetry.Abstractions package-ref * fix glitchy L2 test * better tracking for invalid event-source state * reserve non-printable characters from keys, to prevent L2 abuse * improve test output for ETW * tyop * ETW tests: allow longer if needed * whitespace * more ETW fixins --------- Co-authored-by: Jose Perez Rodriguez --- eng/packages/TestOnly.props | 3 +- eng/spellchecking_exclusions.dic | Bin 176 -> 198 bytes .../Internal/DefaultHybridCache.CacheItem.cs | 16 +- .../DefaultHybridCache.ImmutableCacheItem.cs | 3 +- .../Internal/DefaultHybridCache.L2.cs | 12 +- .../DefaultHybridCache.MutableCacheItem.cs | 21 +- .../DefaultHybridCache.Serialization.cs | 52 ++- .../DefaultHybridCache.StampedeState.cs | 2 - .../DefaultHybridCache.StampedeStateT.cs | 177 ++++++++--- .../Internal/DefaultHybridCache.cs | 81 ++++- .../Internal/HybridCacheEventSource.cs | 203 ++++++++++++ .../Internal/InbuiltTypeSerializer.cs | 20 +- .../Internal/Log.cs | 49 +++ .../Internal/RecyclableArrayBufferWriter.cs | 17 +- ...Microsoft.Extensions.Caching.Hybrid.csproj | 7 +- ....Extensions.Compliance.Abstractions.csproj | 1 + .../HybridCacheEventSourceTests.cs | 205 ++++++++++++ .../LogCollector.cs | 84 +++++ ...oft.Extensions.Caching.Hybrid.Tests.csproj | 4 +- .../NullDistributedCache.cs | 31 ++ .../SizeTests.cs | 298 ++++++++++++++++-- .../TestEventListener.cs | 189 +++++++++++ .../UnreliableL2Tests.cs | 251 +++++++++++++++ ...nsions.Telemetry.Abstractions.Tests.csproj | 4 + 24 files changed, 1632 insertions(+), 98 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs create mode 100644 src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/Log.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LogCollector.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/NullDistributedCache.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/UnreliableL2Tests.cs diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index f6753c9c14d..4c78b8dcbe8 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -7,6 +7,7 @@ + @@ -20,7 +21,7 @@ - + diff --git a/eng/spellchecking_exclusions.dic b/eng/spellchecking_exclusions.dic index 2fc9b74699b3a4f15d47904fb03678d52114bd26..7259681651670edef6d5aad2d32ac8843ddc50fe 100644 GIT binary patch delta 29 icmdnMc#Ltv2C@JDk{J>ia)2-iNGCI7Gw?ESF#rIa=?D-2 delta 6 NcmX@cxPfuP1^@{{0=WPH diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs index 5585b9b2a29..05edc65dc06 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Threading; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -22,7 +23,7 @@ internal abstract class CacheItem // zero. // This counter also drives cache lifetime, with the cache itself incrementing the count by one. In the // case of mutable data, cache eviction may reduce this to zero (in cooperation with any concurrent readers, - // who incr/decr around their fetch), allowing safe buffer recycling. + // who increment/decrement around their fetch), allowing safe buffer recycling. internal int RefCount => Volatile.Read(ref _refCount); @@ -89,13 +90,18 @@ internal abstract class CacheItem : CacheItem { public abstract bool TryGetSize(out long size); - // attempt to get a value that was *not* previously reserved - public abstract bool TryGetValue(out T value); + // Attempt to get a value that was *not* previously reserved. + // Note on ILogger usage: we don't want to propagate and store this everywhere. + // It is used for reporting deserialization problems - pass it as needed. + // (CacheItem gets into the IMemoryCache - let's minimize the onward reachable set + // of that cache, by only handing it leaf nodes of a "tree", not a "graph" with + // backwards access - we can also limit object size at the same time) + public abstract bool TryGetValue(ILogger log, out T value); // get a value that *was* reserved, countermanding our reservation in the process - public T GetReservedValue() + public T GetReservedValue(ILogger log) { - if (!TryGetValue(out var value)) + if (!TryGetValue(log, out var value)) { Throw(); } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs index 9ae8468ba29..2e803d87ad6 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Threading; +using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -38,7 +39,7 @@ public void SetValue(T value, long size) Size = size; } - public override bool TryGetValue(out T value) + public override bool TryGetValue(ILogger log, out T value) { value = _value; return true; // always available diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs index 1e694448737..230a657bdc3 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs @@ -16,12 +16,16 @@ internal partial class DefaultHybridCache { [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Manual sync check")] [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Manual sync check")] + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Explicit async exception handling")] + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Deliberate recycle only on success")] internal ValueTask GetFromL2Async(string key, CancellationToken token) { switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) { case CacheFeatures.BackendCache: // legacy byte[]-based + var pendingLegacy = _backendCache!.GetAsync(key, token); + #if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER if (!pendingLegacy.IsCompletedSuccessfully) #else @@ -36,6 +40,7 @@ internal ValueTask GetFromL2Async(string key, CancellationToken tok case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // IBufferWriter-based RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); var cache = Unsafe.As(_backendCache!); // type-checked already + var pendingBuffers = cache.TryGetAsync(key, writer, token); if (!pendingBuffers.IsCompletedSuccessfully) { @@ -49,7 +54,7 @@ internal ValueTask GetFromL2Async(string key, CancellationToken tok return new(result); } - return default; + return default; // treat as a "miss" static async Task AwaitedLegacyAsync(Task pending, DefaultHybridCache @this) { @@ -115,6 +120,11 @@ internal void SetL1(string key, CacheItem value, HybridCacheEntryOptions? // commit cacheEntry.Dispose(); + + if (HybridCacheEventSource.Log.IsEnabled()) + { + HybridCacheEventSource.Log.LocalCacheWrite(); + } } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs index 2d02c23b6d8..db95e8c4590 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs @@ -1,14 +1,18 @@ // 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.Logging; + namespace Microsoft.Extensions.Caching.Hybrid.Internal; internal partial class DefaultHybridCache { private sealed partial class MutableCacheItem : CacheItem // used to hold types that require defensive copies { - private IHybridCacheSerializer _serializer = null!; // deferred until SetValue + private IHybridCacheSerializer? _serializer; private BufferChunk _buffer; + private T? _fallbackValue; // only used in the case of serialization failures public override bool NeedsEvictionCallback => _buffer.ReturnToPool; @@ -21,16 +25,27 @@ public void SetValue(ref BufferChunk buffer, IHybridCacheSerializer serialize buffer = default; // we're taking over the lifetime; the caller no longer has it! } - public override bool TryGetValue(out T value) + public void SetFallbackValue(T fallbackValue) + { + _fallbackValue = fallbackValue; + } + + public override bool TryGetValue(ILogger log, out T value) { // only if we haven't already burned if (TryReserve()) { try { - value = _serializer.Deserialize(_buffer.AsSequence()); + var serializer = _serializer; + value = serializer is null ? _fallbackValue! : serializer.Deserialize(_buffer.AsSequence()); return true; } + catch (Exception ex) + { + log.DeserializationFailure(ex); + throw; + } finally { _ = Release(); diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs index 523a95e279a..d12b2cce592 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Concurrent; -using System.Reflection; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; @@ -51,4 +51,54 @@ static IHybridCacheSerializer ResolveAndAddSerializer(DefaultHybridCache @thi return serializer; } } + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Intentional for logged failure mode")] + private bool TrySerialize(T value, out BufferChunk buffer, out IHybridCacheSerializer? serializer) + { + // note: also returns the serializer we resolved, because most-any time we want to serialize, we'll also want + // to make sure we use that same instance later (without needing to re-resolve and/or store the entire HC machinery) + + RecyclableArrayBufferWriter? writer = null; + buffer = default; + try + { + writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); // note this lifetime spans the SetL2Async + serializer = GetSerializer(); + + serializer.Serialize(value, writer); + + buffer = new(writer.DetachCommitted(out var length), length, returnToPool: true); // remove buffer ownership from the writer + writer.Dispose(); // we're done with the writer + return true; + } + catch (Exception ex) + { + bool knownCause = false; + + // ^^^ if we know what happened, we can record directly via cause-specific events + // and treat as a handled failure (i.e. return false) - otherwise, we'll bubble + // the fault up a few layers *in addition to* logging in a failure event + + if (writer is not null) + { + if (writer.QuotaExceeded) + { + _logger.MaximumPayloadBytesExceeded(ex, MaximumPayloadBytes); + knownCause = true; + } + + writer.Dispose(); + } + + if (!knownCause) + { + _logger.SerializationFailure(ex); + throw; + } + + buffer = default; + serializer = null; + return false; + } + } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeState.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeState.cs index eba71774395..e2439357f26 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeState.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeState.cs @@ -74,8 +74,6 @@ protected StampedeState(DefaultHybridCache cache, in StampedeKey key, CacheItem public abstract void Execute(); - protected int MaximumPayloadBytes => _cache.MaximumPayloadBytes; - public override string ToString() => Key.ToString(); public abstract void SetCanceled(); diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs index 4e45acae930..4be5b351485 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using static Microsoft.Extensions.Caching.Hybrid.Internal.DefaultHybridCache; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -14,7 +15,8 @@ internal partial class DefaultHybridCache { internal sealed class StampedeState : StampedeState { - private const HybridCacheEntryFlags FlagsDisableL1AndL2 = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite; + // note on terminology: L1 and L2 are, for brevity, used interchangeably with "local" and "distributed" cache, i.e. `IMemoryCache` and `IDistributedCache` + private const HybridCacheEntryFlags FlagsDisableL1AndL2Write = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite; private readonly TaskCompletionSource>? _result; private TState? _state; @@ -76,13 +78,13 @@ public Task ExecuteDirectAsync(in TState state, Func _result?.TrySetCanceled(SharedToken); [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Custom task management")] - public ValueTask JoinAsync(CancellationToken token) + public ValueTask JoinAsync(ILogger log, CancellationToken token) { // If the underlying has already completed, and/or our local token can't cancel: we // can simply wrap the shared task; otherwise, we need our own cancellation state. - return token.CanBeCanceled && !Task.IsCompleted ? WithCancellationAsync(this, token) : UnwrapReservedAsync(); + return token.CanBeCanceled && !Task.IsCompleted ? WithCancellationAsync(log, this, token) : UnwrapReservedAsync(log); - static async ValueTask WithCancellationAsync(StampedeState stampede, CancellationToken token) + static async ValueTask WithCancellationAsync(ILogger log, StampedeState stampede, CancellationToken token) { var cancelStub = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var reg = token.Register(static obj => @@ -112,7 +114,7 @@ static async ValueTask WithCancellationAsync(StampedeState stamped } // outside the catch, so we know we only decrement one way or the other - return result.GetReservedValue(); + return result.GetReservedValue(log); } } @@ -133,7 +135,7 @@ static Task> InvalidAsync() => System.Threading.Tasks.Task.FromExce [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Checked manual unwrap")] [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Checked manual unwrap")] [SuppressMessage("Major Code Smell", "S1121:Assignments should not be made from within sub-expressions", Justification = "Unusual, but legit here")] - internal ValueTask UnwrapReservedAsync() + internal ValueTask UnwrapReservedAsync(ILogger log) { var task = Task; #if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER @@ -142,16 +144,16 @@ internal ValueTask UnwrapReservedAsync() if (task.Status == TaskStatus.RanToCompletion) #endif { - return new(task.Result.GetReservedValue()); + return new(task.Result.GetReservedValue(log)); } // if the type is immutable, callers can share the final step too (this may leave dangling // reservation counters, but that's OK) - var result = ImmutableTypeCache.IsImmutable ? (_sharedUnwrap ??= AwaitedAsync(Task)) : AwaitedAsync(Task); + var result = ImmutableTypeCache.IsImmutable ? (_sharedUnwrap ??= AwaitedAsync(log, Task)) : AwaitedAsync(log, Task); return new(result); - static async Task AwaitedAsync(Task> task) - => (await task.ConfigureAwait(false)).GetReservedValue(); + static async Task AwaitedAsync(ILogger log, Task> task) + => (await task.ConfigureAwait(false)).GetReservedValue(log); } [DoesNotReturn] @@ -161,12 +163,43 @@ static async Task AwaitedAsync(Task> task) [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Exception is passed through to faulted task result")] private async Task BackgroundFetchAsync() { + bool eventSourceEnabled = HybridCacheEventSource.Log.IsEnabled(); try { // read from L2 if appropriate if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheRead) == 0) { - var result = await Cache.GetFromL2Async(Key.Key, SharedToken).ConfigureAwait(false); + BufferChunk result; + try + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.DistributedCacheGet(); + } + + result = await Cache.GetFromL2Async(Key.Key, SharedToken).ConfigureAwait(false); + if (eventSourceEnabled) + { + if (result.Array is not null) + { + HybridCacheEventSource.Log.DistributedCacheHit(); + } + else + { + HybridCacheEventSource.Log.DistributedCacheMiss(); + } + } + } + catch (Exception ex) + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.DistributedCacheFailed(); + } + + Cache._logger.CacheUnderlyingDataQueryFailure(ex); + result = default; // treat as "miss" + } if (result.Array is not null) { @@ -179,7 +212,30 @@ private async Task BackgroundFetchAsync() if ((Key.Flags & HybridCacheEntryFlags.DisableUnderlyingData) == 0) { // invoke the callback supplied by the caller - T newValue = await _underlying!(_state!, SharedToken).ConfigureAwait(false); + T newValue; + try + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.UnderlyingDataQueryStart(); + } + + newValue = await _underlying!(_state!, SharedToken).ConfigureAwait(false); + + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.UnderlyingDataQueryComplete(); + } + } + catch + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.UnderlyingDataQueryFailed(); + } + + throw; + } // If we're writing this value *anywhere*, we're going to need to serialize; this is obvious // in the case of L2, but we also need it for L1, because MemoryCache might be enforcing @@ -187,11 +243,11 @@ private async Task BackgroundFetchAsync() // Likewise, if we're writing to a MutableCacheItem, we'll be serializing *anyway* for the payload. // // Rephrasing that: the only scenario in which we *do not* need to serialize is if: - // - it is an ImmutableCacheItem - // - we're writing neither to L1 nor L2 + // - it is an ImmutableCacheItem (so we don't need bytes for the CacheItem, L1) + // - we're not writing to L2 CacheItem cacheItem = CacheItem; - bool skipSerialize = cacheItem is ImmutableCacheItem && (Key.Flags & FlagsDisableL1AndL2) == FlagsDisableL1AndL2; + bool skipSerialize = cacheItem is ImmutableCacheItem && (Key.Flags & FlagsDisableL1AndL2Write) == FlagsDisableL1AndL2Write; if (skipSerialize) { @@ -202,33 +258,55 @@ private async Task BackgroundFetchAsync() // ^^^ The first thing we need to do is make sure we're not getting into a thread race over buffer disposal. // In particular, if this cache item is somehow so short-lived that the buffers would be released *before* we're // done writing them to L2, which happens *after* we've provided the value to consumers. - RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); // note this lifetime spans the SetL2Async - IHybridCacheSerializer serializer = Cache.GetSerializer(); - serializer.Serialize(newValue, writer); - BufferChunk buffer = new(writer.DetachCommitted(out var length), length, returnToPool: true); // remove buffer ownership from the writer - writer.Dispose(); // we're done with the writer - - // protect "buffer" (this is why we "reserved") for writing to L2 if needed; SetResultPreSerialized - // *may* (depending on context) claim this buffer, in which case "bufferToRelease" gets reset, and - // the final RecycleIfAppropriate() is a no-op; however, the buffer is valid in either event, - // (with TryReserve above guaranteeing that we aren't in a race condition). - BufferChunk bufferToRelease = buffer; - - // and since "bufferToRelease" is the thing that will be returned at some point, we can make it explicit - // that we do not need or want "buffer" to do any recycling (they're the same memory) - buffer = buffer.DoNotReturnToPool(); - - // set the underlying result for this operation (includes L1 write if appropriate) - SetResultPreSerialized(newValue, ref bufferToRelease, serializer); - - // Note that at this point we've already released most or all of the waiting callers. Everything - // from this point onwards happens in the background, from the perspective of the calling code. - - // Write to L2 if appropriate. - if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0) + + BufferChunk bufferToRelease = default; + if (Cache.TrySerialize(newValue, out var buffer, out var serializer)) { - // We already have the payload serialized, so this is trivial to do. - await Cache.SetL2Async(Key.Key, in buffer, _options, SharedToken).ConfigureAwait(false); + // note we also capture the resolved serializer ^^^ - we'll need it again later + + // protect "buffer" (this is why we "reserved") for writing to L2 if needed; SetResultPreSerialized + // *may* (depending on context) claim this buffer, in which case "bufferToRelease" gets reset, and + // the final RecycleIfAppropriate() is a no-op; however, the buffer is valid in either event, + // (with TryReserve above guaranteeing that we aren't in a race condition). + bufferToRelease = buffer; + + // and since "bufferToRelease" is the thing that will be returned at some point, we can make it explicit + // that we do not need or want "buffer" to do any recycling (they're the same memory) + buffer = buffer.DoNotReturnToPool(); + + // set the underlying result for this operation (includes L1 write if appropriate) + SetResultPreSerialized(newValue, ref bufferToRelease, serializer); + + // Note that at this point we've already released most or all of the waiting callers. Everything + // from this point onwards happens in the background, from the perspective of the calling code. + + // Write to L2 if appropriate. + if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0) + { + // We already have the payload serialized, so this is trivial to do. + try + { + await Cache.SetL2Async(Key.Key, in buffer, _options, SharedToken).ConfigureAwait(false); + + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.DistributedCacheWrite(); + } + } + catch (Exception ex) + { + // log the L2 write failure, but that doesn't need to interrupt the app flow (so: + // don't rethrow); L1 will still reduce impact, and L1 without L2 is better than + // hard failure every time + Cache._logger.CacheBackendWriteFailure(ex); + } + } + } + else + { + // unable to serialize (or quota exceeded); try to at least store the onwards value; this is + // especially useful for immutable data types + SetResultPreSerialized(newValue, ref bufferToRelease, serializer); } // Release our hook on the CacheItem (only really important for "mutable"). @@ -309,7 +387,7 @@ private void SetResultAndRecycleIfAppropriate(ref BufferChunk value) private void SetImmutableResultWithoutSerialize(T value) { - Debug.Assert((Key.Flags & FlagsDisableL1AndL2) == FlagsDisableL1AndL2, "Only expected if L1+L2 disabled"); + Debug.Assert((Key.Flags & FlagsDisableL1AndL2Write) == FlagsDisableL1AndL2Write, "Only expected if L1+L2 disabled"); // set a result from a value we calculated directly CacheItem cacheItem; @@ -328,7 +406,7 @@ private void SetImmutableResultWithoutSerialize(T value) SetResult(cacheItem); } - private void SetResultPreSerialized(T value, ref BufferChunk buffer, IHybridCacheSerializer serializer) + private void SetResultPreSerialized(T value, ref BufferChunk buffer, IHybridCacheSerializer? serializer) { // set a result from a value we calculated directly that // has ALREADY BEEN SERIALIZED (we can optionally consume this buffer) @@ -343,8 +421,17 @@ private void SetResultPreSerialized(T value, ref BufferChunk buffer, IHybridCach // (but leave the buffer alone) break; case MutableCacheItem mutable: - mutable.SetValue(ref buffer, serializer); - mutable.DebugOnlyTrackBuffer(Cache); + if (serializer is null) + { + // serialization is failing; set fallback value + mutable.SetFallbackValue(value); + } + else + { + mutable.SetValue(ref buffer, serializer); + mutable.DebugOnlyTrackBuffer(Cache); + } + cacheItem = mutable; break; default: diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs index c789e7c6652..71dbf71fd54 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs @@ -22,6 +22,9 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; /// internal sealed partial class DefaultHybridCache : HybridCache { + // reserve non-printable characters from keys, to prevent potential L2 abuse + private static readonly char[] _keyReservedCharacters = Enumerable.Range(0, 32).Select(i => (char)i).ToArray(); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0032:Use auto property", Justification = "Keep usage explicit")] private readonly IDistributedCache? _backendCache; [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0032:Use auto property", Justification = "Keep usage explicit")] @@ -37,6 +40,7 @@ internal sealed partial class DefaultHybridCache : HybridCache private readonly HybridCacheEntryFlags _defaultFlags; // note this already includes hardFlags private readonly TimeSpan _defaultExpiration; private readonly TimeSpan _defaultLocalCacheExpiration; + private readonly int _maximumKeyLength; private readonly DistributedCacheEntryOptions _defaultDistributedCacheExpiration; @@ -90,6 +94,7 @@ public DefaultHybridCache(IOptions options, IServiceProvider _serializerFactories = factories; MaximumPayloadBytes = checked((int)_options.MaximumPayloadBytes); // for now hard-limit to 2GiB + _maximumKeyLength = _options.MaximumKeyLength; var defaultEntryOptions = _options.DefaultEntryOptions; @@ -119,11 +124,33 @@ public override ValueTask GetOrCreateAsync(string key, TState stat } var flags = GetEffectiveFlags(options); - if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0 && _localCache.TryGetValue(key, out var untyped) - && untyped is CacheItem typed && typed.TryGetValue(out var value)) + if (!ValidateKey(key)) { - // short-circuit - return new(value); + // we can't use cache, but we can still provide the data + return RunWithoutCacheAsync(flags, state, underlyingDataCallback, cancellationToken); + } + + bool eventSourceEnabled = HybridCacheEventSource.Log.IsEnabled(); + if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0) + { + if (_localCache.TryGetValue(key, out var untyped) + && untyped is CacheItem typed && typed.TryGetValue(_logger, out var value)) + { + // short-circuit + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.LocalCacheHit(); + } + + return new(value); + } + else + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.LocalCacheMiss(); + } + } } if (GetOrCreateStampedeState(key, flags, out var stampede, canBeCanceled)) @@ -139,11 +166,19 @@ public override ValueTask GetOrCreateAsync(string key, TState stat { // we're going to run to completion; no need to get complicated _ = stampede.ExecuteDirectAsync(in state, underlyingDataCallback, options); // this larger task includes L2 write etc - return stampede.UnwrapReservedAsync(); + return stampede.UnwrapReservedAsync(_logger); + } + } + else + { + // pre-existing query + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.StampedeJoin(); } } - return stampede.JoinAsync(cancellationToken); + return stampede.JoinAsync(_logger, cancellationToken); } public override ValueTask RemoveAsync(string key, CancellationToken token = default) @@ -164,7 +199,39 @@ public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptio return new(state.ExecuteDirectAsync(value, static (state, _) => new(state), options)); // note this spans L2 write etc } + private static ValueTask RunWithoutCacheAsync(HybridCacheEntryFlags flags, TState state, + Func> underlyingDataCallback, + CancellationToken cancellationToken) + { + return (flags & HybridCacheEntryFlags.DisableUnderlyingData) == 0 + ? underlyingDataCallback(state, cancellationToken) : default; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private HybridCacheEntryFlags GetEffectiveFlags(HybridCacheEntryOptions? options) - => (options?.Flags | _hardFlags) ?? _defaultFlags; + => (options?.Flags | _hardFlags) ?? _defaultFlags; + + private bool ValidateKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + _logger.KeyEmptyOrWhitespace(); + return false; + } + + if (key.Length > _maximumKeyLength) + { + _logger.MaximumKeyLengthExceeded(_maximumKeyLength, key.Length); + return false; + } + + if (key.IndexOfAny(_keyReservedCharacters) >= 0) + { + _logger.KeyInvalidContent(); + return false; + } + + // nothing to complain about + return true; + } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs new file mode 100644 index 00000000000..92a5d729e57 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs @@ -0,0 +1,203 @@ +// 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.Diagnostics.Tracing; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +[EventSource(Name = "Microsoft-Extensions-HybridCache")] +internal sealed class HybridCacheEventSource : EventSource +{ + public static readonly HybridCacheEventSource Log = new(); + + internal const int EventIdLocalCacheHit = 1; + internal const int EventIdLocalCacheMiss = 2; + internal const int EventIdDistributedCacheGet = 3; + internal const int EventIdDistributedCacheHit = 4; + internal const int EventIdDistributedCacheMiss = 5; + internal const int EventIdDistributedCacheFailed = 6; + internal const int EventIdUnderlyingDataQueryStart = 7; + internal const int EventIdUnderlyingDataQueryComplete = 8; + internal const int EventIdUnderlyingDataQueryFailed = 9; + internal const int EventIdLocalCacheWrite = 10; + internal const int EventIdDistributedCacheWrite = 11; + internal const int EventIdStampedeJoin = 12; + + // fast local counters + private long _totalLocalCacheHit; + private long _totalLocalCacheMiss; + private long _totalDistributedCacheHit; + private long _totalDistributedCacheMiss; + private long _totalUnderlyingDataQuery; + private long _currentUnderlyingDataQuery; + private long _currentDistributedFetch; + private long _totalLocalCacheWrite; + private long _totalDistributedCacheWrite; + private long _totalStampedeJoin; + +#if !(NETSTANDARD2_0 || NET462) + // full Counter infrastructure + private DiagnosticCounter[]? _counters; +#endif + + [NonEvent] + public void ResetCounters() + { + Debug.WriteLine($"{nameof(HybridCacheEventSource)} counters reset!"); + + Volatile.Write(ref _totalLocalCacheHit, 0); + Volatile.Write(ref _totalLocalCacheMiss, 0); + Volatile.Write(ref _totalDistributedCacheHit, 0); + Volatile.Write(ref _totalDistributedCacheMiss, 0); + Volatile.Write(ref _totalUnderlyingDataQuery, 0); + Volatile.Write(ref _currentUnderlyingDataQuery, 0); + Volatile.Write(ref _currentDistributedFetch, 0); + Volatile.Write(ref _totalLocalCacheWrite, 0); + Volatile.Write(ref _totalDistributedCacheWrite, 0); + Volatile.Write(ref _totalStampedeJoin, 0); + } + + [Event(EventIdLocalCacheHit, Level = EventLevel.Verbose)] + public void LocalCacheHit() + { + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalLocalCacheHit); + WriteEvent(EventIdLocalCacheHit); + } + + [Event(EventIdLocalCacheMiss, Level = EventLevel.Verbose)] + public void LocalCacheMiss() + { + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalLocalCacheMiss); + WriteEvent(EventIdLocalCacheMiss); + } + + [Event(EventIdDistributedCacheGet, Level = EventLevel.Verbose)] + public void DistributedCacheGet() + { + // should be followed by DistributedCacheHit, DistributedCacheMiss or DistributedCacheFailed + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _currentDistributedFetch); + WriteEvent(EventIdDistributedCacheGet); + } + + [Event(EventIdDistributedCacheHit, Level = EventLevel.Verbose)] + public void DistributedCacheHit() + { + DebugAssertEnabled(); + + // note: not concerned about off-by-one here, i.e. don't panic + // about these two being atomic ref each-other - just the overall shape + _ = Interlocked.Increment(ref _totalDistributedCacheHit); + _ = Interlocked.Decrement(ref _currentDistributedFetch); + WriteEvent(EventIdDistributedCacheHit); + } + + [Event(EventIdDistributedCacheMiss, Level = EventLevel.Verbose)] + public void DistributedCacheMiss() + { + DebugAssertEnabled(); + + // note: not concerned about off-by-one here, i.e. don't panic + // about these two being atomic ref each-other - just the overall shape + _ = Interlocked.Increment(ref _totalDistributedCacheMiss); + _ = Interlocked.Decrement(ref _currentDistributedFetch); + WriteEvent(EventIdDistributedCacheMiss); + } + + [Event(EventIdDistributedCacheFailed, Level = EventLevel.Error)] + public void DistributedCacheFailed() + { + DebugAssertEnabled(); + _ = Interlocked.Decrement(ref _currentDistributedFetch); + WriteEvent(EventIdDistributedCacheFailed); + } + + [Event(EventIdUnderlyingDataQueryStart, Level = EventLevel.Verbose)] + public void UnderlyingDataQueryStart() + { + // should be followed by UnderlyingDataQueryComplete or UnderlyingDataQueryFailed + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalUnderlyingDataQuery); + _ = Interlocked.Increment(ref _currentUnderlyingDataQuery); + WriteEvent(EventIdUnderlyingDataQueryStart); + } + + [Event(EventIdUnderlyingDataQueryComplete, Level = EventLevel.Verbose)] + public void UnderlyingDataQueryComplete() + { + DebugAssertEnabled(); + _ = Interlocked.Decrement(ref _currentUnderlyingDataQuery); + WriteEvent(EventIdUnderlyingDataQueryComplete); + } + + [Event(EventIdUnderlyingDataQueryFailed, Level = EventLevel.Error)] + public void UnderlyingDataQueryFailed() + { + DebugAssertEnabled(); + _ = Interlocked.Decrement(ref _currentUnderlyingDataQuery); + WriteEvent(EventIdUnderlyingDataQueryFailed); + } + + [Event(EventIdLocalCacheWrite, Level = EventLevel.Verbose)] + public void LocalCacheWrite() + { + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalLocalCacheWrite); + WriteEvent(EventIdLocalCacheWrite); + } + + [Event(EventIdDistributedCacheWrite, Level = EventLevel.Verbose)] + public void DistributedCacheWrite() + { + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalDistributedCacheWrite); + WriteEvent(EventIdDistributedCacheWrite); + } + + [Event(EventIdStampedeJoin, Level = EventLevel.Verbose)] + internal void StampedeJoin() + { + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalStampedeJoin); + WriteEvent(EventIdStampedeJoin); + } + +#if !(NETSTANDARD2_0 || NET462) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Lifetime exceeds obvious scope; handed to event source")] + [NonEvent] + protected override void OnEventCommand(EventCommandEventArgs command) + { + if (command.Command == EventCommand.Enable) + { + // lazily create counters on first Enable + _counters ??= [ + new PollingCounter("total-local-cache-hits", this, () => Volatile.Read(ref _totalLocalCacheHit)) { DisplayName = "Total Local Cache Hits" }, + new PollingCounter("total-local-cache-misses", this, () => Volatile.Read(ref _totalLocalCacheMiss)) { DisplayName = "Total Local Cache Misses" }, + new PollingCounter("total-distributed-cache-hits", this, () => Volatile.Read(ref _totalDistributedCacheHit)) { DisplayName = "Total Distributed Cache Hits" }, + new PollingCounter("total-distributed-cache-misses", this, () => Volatile.Read(ref _totalDistributedCacheMiss)) { DisplayName = "Total Distributed Cache Misses" }, + new PollingCounter("total-data-query", this, () => Volatile.Read(ref _totalUnderlyingDataQuery)) { DisplayName = "Total Data Queries" }, + new PollingCounter("current-data-query", this, () => Volatile.Read(ref _currentUnderlyingDataQuery)) { DisplayName = "Current Data Queries" }, + new PollingCounter("current-distributed-cache-fetches", this, () => Volatile.Read(ref _currentDistributedFetch)) { DisplayName = "Current Distributed Cache Fetches" }, + new PollingCounter("total-local-cache-writes", this, () => Volatile.Read(ref _totalLocalCacheWrite)) { DisplayName = "Total Local Cache Writes" }, + new PollingCounter("total-distributed-cache-writes", this, () => Volatile.Read(ref _totalDistributedCacheWrite)) { DisplayName = "Total Distributed Cache Writes" }, + new PollingCounter("total-stampede-joins", this, () => Volatile.Read(ref _totalStampedeJoin)) { DisplayName = "Total Stampede Joins" }, + ]; + } + + base.OnEventCommand(command); + } +#endif + + [NonEvent] + [Conditional("DEBUG")] + private void DebugAssertEnabled([CallerMemberName] string caller = "") + { + Debug.Assert(IsEnabled(), $"Missing check to {nameof(HybridCacheEventSource)}.{nameof(Log)}.{nameof(IsEnabled)} from {caller}"); + Debug.WriteLine($"{nameof(HybridCacheEventSource)}: {caller}"); // also log all event calls, for visibility + } +} diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/InbuiltTypeSerializer.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/InbuiltTypeSerializer.cs index 3ef26341433..4800428a88f 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/InbuiltTypeSerializer.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/InbuiltTypeSerializer.cs @@ -17,6 +17,18 @@ internal sealed class InbuiltTypeSerializer : IHybridCacheSerializer, IH public static InbuiltTypeSerializer Instance { get; } = new(); string IHybridCacheSerializer.Deserialize(ReadOnlySequence source) + => DeserializeString(source); + + void IHybridCacheSerializer.Serialize(string value, IBufferWriter target) + => SerializeString(value, target); + + byte[] IHybridCacheSerializer.Deserialize(ReadOnlySequence source) + => source.ToArray(); + + void IHybridCacheSerializer.Serialize(byte[] value, IBufferWriter target) + => target.Write(value); + + internal static string DeserializeString(ReadOnlySequence source) { #if NET5_0_OR_GREATER return Encoding.UTF8.GetString(source); @@ -36,7 +48,7 @@ string IHybridCacheSerializer.Deserialize(ReadOnlySequence source) #endif } - void IHybridCacheSerializer.Serialize(string value, IBufferWriter target) + internal static void SerializeString(string value, IBufferWriter target) { #if NET5_0_OR_GREATER Encoding.UTF8.GetBytes(value, target); @@ -49,10 +61,4 @@ void IHybridCacheSerializer.Serialize(string value, IBufferWriter ArrayPool.Shared.Return(oversized); #endif } - - byte[] IHybridCacheSerializer.Deserialize(ReadOnlySequence source) - => source.ToArray(); - - void IHybridCacheSerializer.Serialize(byte[] value, IBufferWriter target) - => target.Write(value); } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/Log.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/Log.cs new file mode 100644 index 00000000000..785107c32ec --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/Log.cs @@ -0,0 +1,49 @@ +// 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.Logging; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +internal static partial class Log +{ + internal const int IdMaximumPayloadBytesExceeded = 1; + internal const int IdSerializationFailure = 2; + internal const int IdDeserializationFailure = 3; + internal const int IdKeyEmptyOrWhitespace = 4; + internal const int IdMaximumKeyLengthExceeded = 5; + internal const int IdCacheBackendReadFailure = 6; + internal const int IdCacheBackendWriteFailure = 7; + internal const int IdKeyInvalidContent = 8; + + [LoggerMessage(LogLevel.Error, "Cache MaximumPayloadBytes ({Bytes}) exceeded.", EventName = "MaximumPayloadBytesExceeded", EventId = IdMaximumPayloadBytesExceeded, SkipEnabledCheck = false)] + internal static partial void MaximumPayloadBytesExceeded(this ILogger logger, Exception e, int bytes); + + // note that serialization is critical enough that we perform hard failures in addition to logging; serialization + // failures are unlikely to be transient (i.e. connectivity); we would rather this shows up in QA, rather than + // being invisible and people *thinking* they're using cache, when actually they are not + + [LoggerMessage(LogLevel.Error, "Cache serialization failure.", EventName = "SerializationFailure", EventId = IdSerializationFailure, SkipEnabledCheck = false)] + internal static partial void SerializationFailure(this ILogger logger, Exception e); + + // (see same notes per SerializationFailure) + [LoggerMessage(LogLevel.Error, "Cache deserialization failure.", EventName = "DeserializationFailure", EventId = IdDeserializationFailure, SkipEnabledCheck = false)] + internal static partial void DeserializationFailure(this ILogger logger, Exception e); + + [LoggerMessage(LogLevel.Error, "Cache key empty or whitespace.", EventName = "KeyEmptyOrWhitespace", EventId = IdKeyEmptyOrWhitespace, SkipEnabledCheck = false)] + internal static partial void KeyEmptyOrWhitespace(this ILogger logger); + + [LoggerMessage(LogLevel.Error, "Cache key maximum length exceeded (maximum: {MaxLength}, actual: {KeyLength}).", EventName = "MaximumKeyLengthExceeded", + EventId = IdMaximumKeyLengthExceeded, SkipEnabledCheck = false)] + internal static partial void MaximumKeyLengthExceeded(this ILogger logger, int maxLength, int keyLength); + + [LoggerMessage(LogLevel.Error, "Cache backend read failure.", EventName = "CacheBackendReadFailure", EventId = IdCacheBackendReadFailure, SkipEnabledCheck = false)] + internal static partial void CacheUnderlyingDataQueryFailure(this ILogger logger, Exception ex); + + [LoggerMessage(LogLevel.Error, "Cache backend write failure.", EventName = "CacheBackendWriteFailure", EventId = IdCacheBackendWriteFailure, SkipEnabledCheck = false)] + internal static partial void CacheBackendWriteFailure(this ILogger logger, Exception ex); + + [LoggerMessage(LogLevel.Error, "Cache key contains invalid content.", EventName = "KeyInvalidContent", EventId = IdKeyInvalidContent, SkipEnabledCheck = false)] + internal static partial void KeyInvalidContent(this ILogger logger); // for PII etc reasons, we won't include the actual key +} diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs index 2f2da2c7019..985d55c9f0e 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs @@ -46,20 +46,20 @@ internal sealed class RecyclableArrayBufferWriter : IBufferWriter, IDispos public int CommittedBytes => _index; public int FreeCapacity => _buffer.Length - _index; + public bool QuotaExceeded { get; private set; } + private static RecyclableArrayBufferWriter? _spare; + public static RecyclableArrayBufferWriter Create(int maxLength) { var obj = Interlocked.Exchange(ref _spare, null) ?? new(); - Debug.Assert(obj._index == 0, "index should be zero initially"); - obj._maxLength = maxLength; + obj.Initialize(maxLength); return obj; } private RecyclableArrayBufferWriter() { _buffer = []; - _index = 0; - _maxLength = int.MaxValue; } public void Dispose() @@ -91,6 +91,7 @@ public void Advance(int count) if (_index + count > _maxLength) { + QuotaExceeded = true; ThrowQuota(); } @@ -199,4 +200,12 @@ private void CheckAndResizeBuffer(int sizeHint) static void ThrowOutOfMemoryException() => throw new InvalidOperationException("Unable to grow buffer as requested"); } + + private void Initialize(int maxLength) + { + // think .ctor, but with pooled object re-use + _index = 0; + _maxLength = maxLength; + QuotaExceeded = false; + } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj index 1c59ccc088a..dfa70cd121e 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj @@ -4,7 +4,7 @@ Multi-level caching implementation building on and extending IDistributedCache $(NetCoreTargetFrameworks)$(ConditionalNet462);netstandard2.0;netstandard2.1 true - cache;distributedcache;hybrid + cache;distributedcache;hybridcache true true true @@ -20,6 +20,11 @@ true + true + true + + + false diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj index 5a6c93e1dc7..c83b7284da5 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Compliance + $(NetCoreTargetFrameworks);netstandard2.0; Abstractions to help ensure compliant data management. Fundamentals diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs new file mode 100644 index 00000000000..3a266af7ce3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs @@ -0,0 +1,205 @@ +// 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.Tracing; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public class HybridCacheEventSourceTests(ITestOutputHelper log, TestEventListener listener) : IClassFixture +{ + // see notes in TestEventListener for context on fixture usage + + [SkippableFact] + public void MatchesNameAndGuid() + { + // Assert + Assert.Equal("Microsoft-Extensions-HybridCache", listener.Source.Name); + Assert.Equal(Guid.Parse("b3aca39e-5dc9-5e21-f669-b72225b66cfc"), listener.Source.Guid); // from name + } + + [SkippableFact] + public async Task LocalCacheHit() + { + AssertEnabled(); + + listener.Reset().Source.LocalCacheHit(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdLocalCacheHit, "LocalCacheHit", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-local-cache-hits", "Total Local Cache Hits", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task LocalCacheMiss() + { + AssertEnabled(); + + listener.Reset().Source.LocalCacheMiss(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdLocalCacheMiss, "LocalCacheMiss", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-local-cache-misses", "Total Local Cache Misses", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task DistributedCacheGet() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheGet(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheGet, "DistributedCacheGet", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("current-distributed-cache-fetches", "Current Distributed Cache Fetches", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task DistributedCacheHit() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheGet(); + listener.Reset(resetCounters: false).Source.DistributedCacheHit(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheHit, "DistributedCacheHit", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-distributed-cache-hits", "Total Distributed Cache Hits", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task DistributedCacheMiss() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheGet(); + listener.Reset(resetCounters: false).Source.DistributedCacheMiss(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheMiss, "DistributedCacheMiss", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-distributed-cache-misses", "Total Distributed Cache Misses", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task DistributedCacheFailed() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheGet(); + listener.Reset(resetCounters: false).Source.DistributedCacheFailed(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheFailed, "DistributedCacheFailed", EventLevel.Error); + + await AssertCountersAsync(); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task UnderlyingDataQueryStart() + { + AssertEnabled(); + + listener.Reset().Source.UnderlyingDataQueryStart(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdUnderlyingDataQueryStart, "UnderlyingDataQueryStart", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("current-data-query", "Current Data Queries", 1); + listener.AssertCounter("total-data-query", "Total Data Queries", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task UnderlyingDataQueryComplete() + { + AssertEnabled(); + + listener.Reset().Source.UnderlyingDataQueryStart(); + listener.Reset(resetCounters: false).Source.UnderlyingDataQueryComplete(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdUnderlyingDataQueryComplete, "UnderlyingDataQueryComplete", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-data-query", "Total Data Queries", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task UnderlyingDataQueryFailed() + { + AssertEnabled(); + + listener.Reset().Source.UnderlyingDataQueryStart(); + listener.Reset(resetCounters: false).Source.UnderlyingDataQueryFailed(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdUnderlyingDataQueryFailed, "UnderlyingDataQueryFailed", EventLevel.Error); + + await AssertCountersAsync(); + listener.AssertCounter("total-data-query", "Total Data Queries", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task LocalCacheWrite() + { + AssertEnabled(); + + listener.Reset().Source.LocalCacheWrite(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdLocalCacheWrite, "LocalCacheWrite", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-local-cache-writes", "Total Local Cache Writes", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task DistributedCacheWrite() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheWrite(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheWrite, "DistributedCacheWrite", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-distributed-cache-writes", "Total Distributed Cache Writes", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task StampedeJoin() + { + AssertEnabled(); + + listener.Reset().Source.StampedeJoin(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdStampedeJoin, "StampedeJoin", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-stampede-joins", "Total Stampede Joins", 1); + listener.AssertRemainingCountersZero(); + } + + private void AssertEnabled() + { + // including this data for visibility when tests fail - ETW subsystem can be ... weird + log.WriteLine($".NET {Environment.Version} on {Environment.OSVersion}, {IntPtr.Size * 8}-bit"); + + Skip.IfNot(listener.Source.IsEnabled(), "Event source not enabled"); + } + + private async Task AssertCountersAsync() + { + var count = await listener.TryAwaitCountersAsync(); + + // ETW counters timing can be painfully unpredictable; generally + // it'll work fine locally, especially on modern .NET, but: + // CI servers and netfx in particular - not so much. The tests + // can still observe and validate the simple events, though, which + // should be enough to be credible that the eventing system is + // fundamentally working. We're not meant to be testing that + // the counters system *itself* works! + + Skip.If(count == 0, "No counters received"); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LogCollector.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LogCollector.cs new file mode 100644 index 00000000000..bdb5ff981c0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LogCollector.cs @@ -0,0 +1,84 @@ +// 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; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +// dummy implementation for collecting test output +internal class LogCollector : ILoggerProvider +{ + private readonly List<(string categoryName, LogLevel logLevel, EventId eventId, Exception? exception, string message)> _items = []; + + public (string categoryName, LogLevel logLevel, EventId eventId, Exception? exception, string message)[] ToArray() + { + lock (_items) + { + return _items.ToArray(); + } + } + + public void WriteTo(ITestOutputHelper log) + { + lock (_items) + { + foreach (var logItem in _items) + { + var errSuffix = logItem.exception is null ? "" : $" - {logItem.exception.Message}"; + log.WriteLine($"{logItem.categoryName} {logItem.eventId}: {logItem.message}{errSuffix}"); + } + } + } + + public void AssertErrors(int[] errorIds) + { + lock (_items) + { + bool same; + if (errorIds.Length == _items.Count) + { + int index = 0; + same = true; + foreach (var item in _items) + { + if (item.eventId.Id != errorIds[index++]) + { + same = false; + break; + } + } + } + else + { + same = false; + } + + if (!same) + { + // we expect this to fail, then + Assert.Equal(string.Join(",", errorIds), string.Join(",", _items.Select(static x => x.eventId.Id))); + } + } + } + + ILogger ILoggerProvider.CreateLogger(string categoryName) => new TypedLogCollector(this, categoryName); + + void IDisposable.Dispose() + { + // nothing to do + } + + private sealed class TypedLogCollector(LogCollector parent, string categoryName) : ILogger + { + IDisposable? ILogger.BeginScope(TState state) => null; + bool ILogger.IsEnabled(LogLevel logLevel) => true; + void ILogger.Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (parent._items) + { + parent._items.Add((categoryName, logLevel, eventId, exception, formatter(state, exception))); + } + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj index ef80a84eee9..fb8863cf776 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -12,13 +12,15 @@ + - + + diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/NullDistributedCache.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/NullDistributedCache.cs new file mode 100644 index 00000000000..d07cb51bb93 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/NullDistributedCache.cs @@ -0,0 +1,31 @@ +// 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.Caching.Distributed; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +// dummy L2 that doesn't actually store anything +internal class NullDistributedCache : IDistributedCache +{ + byte[]? IDistributedCache.Get(string key) => null; + Task IDistributedCache.GetAsync(string key, CancellationToken token) => Task.FromResult(null); + void IDistributedCache.Refresh(string key) + { + // nothing to do + } + + Task IDistributedCache.RefreshAsync(string key, CancellationToken token) => Task.CompletedTask; + void IDistributedCache.Remove(string key) + { + // nothing to do + } + + Task IDistributedCache.RemoveAsync(string key, CancellationToken token) => Task.CompletedTask; + void IDistributedCache.Set(string key, byte[] value, DistributedCacheEntryOptions options) + { + // nothing to do + } + + Task IDistributedCache.SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token) => Task.CompletedTask; +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs index 119c2297882..66f4fc7628d 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs @@ -1,31 +1,60 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; +using System.ComponentModel; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; namespace Microsoft.Extensions.Caching.Hybrid.Tests; -public class SizeTests +public class SizeTests(ITestOutputHelper log) { [Theory] - [InlineData(null, true)] // does not enforce size limits - [InlineData(8L, false)] // unreasonably small limit; chosen because our test string has length 12 - hence no expectation to find the second time - [InlineData(1024L, true)] // reasonable size limit - public async Task ValidateSizeLimit_Immutable(long? sizeLimit, bool expectFromL1) + [InlineData("abc", null, true, null, null)] // does not enforce size limits + [InlineData("", null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData(" ", null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData(null, null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData("abc", 8L, false, null, null)] // unreasonably small limit; chosen because our test string has length 12 - hence no expectation to find the second time + [InlineData("abc", 1024L, true, null, null)] // reasonable size limit + [InlineData("abc", 1024L, true, 8L, null, Log.IdMaximumPayloadBytesExceeded)] // reasonable size limit, small HC quota + [InlineData("abc", null, false, null, 2, Log.IdMaximumKeyLengthExceeded, Log.IdMaximumKeyLengthExceeded)] // key limit exceeded + [InlineData("a\u0000c", null, false, null, null, Log.IdKeyInvalidContent, Log.IdKeyInvalidContent)] // invalid key + [InlineData("a\u001Fc", null, false, null, null, Log.IdKeyInvalidContent, Log.IdKeyInvalidContent)] // invalid key + [InlineData("a\u0020c", null, true, null, null)] // fine (this is just space) + public async Task ValidateSizeLimit_Immutable(string? key, long? sizeLimit, bool expectFromL1, long? maximumPayloadBytes, int? maximumKeyLength, + params int[] errorIds) { + using var collector = new LogCollector(); var services = new ServiceCollection(); services.AddMemoryCache(options => options.SizeLimit = sizeLimit); - services.AddHybridCache(); + services.AddHybridCache(options => + { + if (maximumKeyLength.HasValue) + { + options.MaximumKeyLength = maximumKeyLength.GetValueOrDefault(); + } + + if (maximumPayloadBytes.HasValue) + { + options.MaximumPayloadBytes = maximumPayloadBytes.GetValueOrDefault(); + } + }); + services.AddLogging(options => + { + options.ClearProviders(); + options.AddProvider(collector); + }); using ServiceProvider provider = services.BuildServiceProvider(); var cache = Assert.IsType(provider.GetRequiredService()); - const string Key = "abc"; - // this looks weird; it is intentionally not a const - we want to check // same instance without worrying about interning from raw literals string expected = new("simple value".ToArray()); - var actual = await cache.GetOrCreateAsync(Key, ct => new(expected)); + var actual = await cache.GetOrCreateAsync(key!, ct => new(expected)); // expect same contents Assert.Equal(expected, actual); @@ -35,7 +64,7 @@ public async Task ValidateSizeLimit_Immutable(long? sizeLimit, bool expectFromL1 Assert.Same(expected, actual); // rinse and repeat, to check we get the value from L1 - actual = await cache.GetOrCreateAsync(Key, ct => new(Guid.NewGuid().ToString())); + actual = await cache.GetOrCreateAsync(key!, ct => new(Guid.NewGuid().ToString())); if (expectFromL1) { @@ -51,30 +80,54 @@ public async Task ValidateSizeLimit_Immutable(long? sizeLimit, bool expectFromL1 // L1 cache not used Assert.NotEqual(expected, actual); } + + collector.WriteTo(log); + collector.AssertErrors(errorIds); } [Theory] - [InlineData(null, true)] // does not enforce size limits - [InlineData(8L, false)] // unreasonably small limit; chosen because our test string has length 12 - hence no expectation to find the second time - [InlineData(1024L, true)] // reasonable size limit - public async Task ValidateSizeLimit_Mutable(long? sizeLimit, bool expectFromL1) + [InlineData("abc", null, true, null, null)] // does not enforce size limits + [InlineData("", null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData(" ", null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData(null, null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData("abc", 8L, false, null, null)] // unreasonably small limit; chosen because our test string has length 12 - hence no expectation to find the second time + [InlineData("abc", 1024L, true, null, null)] // reasonable size limit + [InlineData("abc", 1024L, true, 8L, null, Log.IdMaximumPayloadBytesExceeded)] // reasonable size limit, small HC quota + [InlineData("abc", null, false, null, 2, Log.IdMaximumKeyLengthExceeded, Log.IdMaximumKeyLengthExceeded)] // key limit exceeded + public async Task ValidateSizeLimit_Mutable(string? key, long? sizeLimit, bool expectFromL1, long? maximumPayloadBytes, int? maximumKeyLength, + params int[] errorIds) { + using var collector = new LogCollector(); var services = new ServiceCollection(); services.AddMemoryCache(options => options.SizeLimit = sizeLimit); - services.AddHybridCache(); + services.AddHybridCache(options => + { + if (maximumKeyLength.HasValue) + { + options.MaximumKeyLength = maximumKeyLength.GetValueOrDefault(); + } + + if (maximumPayloadBytes.HasValue) + { + options.MaximumPayloadBytes = maximumPayloadBytes.GetValueOrDefault(); + } + }); + services.AddLogging(options => + { + options.ClearProviders(); + options.AddProvider(collector); + }); using ServiceProvider provider = services.BuildServiceProvider(); var cache = Assert.IsType(provider.GetRequiredService()); - const string Key = "abc"; - string expected = "simple value"; - var actual = await cache.GetOrCreateAsync(Key, ct => new(new MutablePoco { Value = expected })); + var actual = await cache.GetOrCreateAsync(key!, ct => new(new MutablePoco { Value = expected })); // expect same contents Assert.Equal(expected, actual.Value); // rinse and repeat, to check we get the value from L1 - actual = await cache.GetOrCreateAsync(Key, ct => new(new MutablePoco { Value = Guid.NewGuid().ToString() })); + actual = await cache.GetOrCreateAsync(key!, ct => new(new MutablePoco { Value = Guid.NewGuid().ToString() })); if (expectFromL1) { @@ -86,10 +139,217 @@ public async Task ValidateSizeLimit_Mutable(long? sizeLimit, bool expectFromL1) // L1 cache not used Assert.NotEqual(expected, actual.Value); } + + collector.WriteTo(log); + collector.AssertErrors(errorIds); + } + + [Theory] + [InlineData("some value", false, 1, 1, 2, false)] + [InlineData("read fail", false, 1, 1, 1, true, Log.IdDeserializationFailure)] + [InlineData("write fail", true, 1, 1, 0, true, Log.IdSerializationFailure)] + public async Task BrokenSerializer_Mutable(string value, bool same, int runCount, int serializeCount, int deserializeCount, bool expectKnownFailure, params int[] errorIds) + { + using var collector = new LogCollector(); + var services = new ServiceCollection(); + services.AddMemoryCache(); + services.AddSingleton(); + var serializer = new MutablePoco.Serializer(); + services.AddHybridCache().AddSerializer(serializer); + services.AddLogging(options => + { + options.ClearProviders(); + options.AddProvider(collector); + }); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + int actualRunCount = 0; + Func> func = _ => + { + Interlocked.Increment(ref actualRunCount); + return new(new MutablePoco { Value = value }); + }; + + if (expectKnownFailure) + { + await Assert.ThrowsAsync(async () => await cache.GetOrCreateAsync("key", func)); + } + else + { + var first = await cache.GetOrCreateAsync("key", func); + var second = await cache.GetOrCreateAsync("key", func); + Assert.Equal(value, first.Value); + Assert.Equal(value, second.Value); + + if (same) + { + Assert.Same(first, second); + } + else + { + Assert.NotSame(first, second); + } + } + + Assert.Equal(runCount, Volatile.Read(ref actualRunCount)); + Assert.Equal(serializeCount, serializer.WriteCount); + Assert.Equal(deserializeCount, serializer.ReadCount); + collector.WriteTo(log); + collector.AssertErrors(errorIds); + } + + [Theory] + [InlineData("some value", true, 1, 1, 0, false, true)] + [InlineData("read fail", true, 1, 1, 0, false, true)] + [InlineData("write fail", true, 1, 1, 0, true, true, Log.IdSerializationFailure)] + + // without L2, we only need the serializer for sizing purposes (L1), not used for deserialize + [InlineData("some value", true, 1, 1, 0, false, false)] + [InlineData("read fail", true, 1, 1, 0, false, false)] + [InlineData("write fail", true, 1, 1, 0, true, false, Log.IdSerializationFailure)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Test scenario range; reducing duplication")] + public async Task BrokenSerializer_Immutable(string value, bool same, int runCount, int serializeCount, int deserializeCount, bool expectKnownFailure, bool withL2, + params int[] errorIds) + { + using var collector = new LogCollector(); + var services = new ServiceCollection(); + services.AddMemoryCache(); + if (withL2) + { + services.AddSingleton(); + } + + var serializer = new ImmutablePoco.Serializer(); + services.AddHybridCache().AddSerializer(serializer); + services.AddLogging(options => + { + options.ClearProviders(); + options.AddProvider(collector); + }); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + int actualRunCount = 0; + Func> func = _ => + { + Interlocked.Increment(ref actualRunCount); + return new(new ImmutablePoco(value)); + }; + + if (expectKnownFailure) + { + await Assert.ThrowsAsync(async () => await cache.GetOrCreateAsync("key", func)); + } + else + { + var first = await cache.GetOrCreateAsync("key", func); + var second = await cache.GetOrCreateAsync("key", func); + Assert.Equal(value, first.Value); + Assert.Equal(value, second.Value); + + if (same) + { + Assert.Same(first, second); + } + else + { + Assert.NotSame(first, second); + } + } + + Assert.Equal(runCount, Volatile.Read(ref actualRunCount)); + Assert.Equal(serializeCount, serializer.WriteCount); + Assert.Equal(deserializeCount, serializer.ReadCount); + collector.WriteTo(log); + collector.AssertErrors(errorIds); + } + + public class KnownFailureException : Exception + { + public KnownFailureException(string message) + : base(message) + { + } } public class MutablePoco { public string Value { get; set; } = ""; + + public sealed class Serializer : IHybridCacheSerializer + { + private int _readCount; + private int _writeCount; + + public int ReadCount => Volatile.Read(ref _readCount); + public int WriteCount => Volatile.Read(ref _writeCount); + + public MutablePoco Deserialize(ReadOnlySequence source) + { + Interlocked.Increment(ref _readCount); + var value = InbuiltTypeSerializer.DeserializeString(source); + if (value == "read fail") + { + throw new KnownFailureException("read failure"); + } + + return new MutablePoco { Value = value }; + } + + public void Serialize(MutablePoco value, IBufferWriter target) + { + Interlocked.Increment(ref _writeCount); + if (value.Value == "write fail") + { + throw new KnownFailureException("write failure"); + } + + InbuiltTypeSerializer.SerializeString(value.Value, target); + } + } + } + + [ImmutableObject(true)] + public sealed class ImmutablePoco + { + public ImmutablePoco(string value) + { + Value = value; + } + + public string Value { get; } + + public sealed class Serializer : IHybridCacheSerializer + { + private int _readCount; + private int _writeCount; + + public int ReadCount => Volatile.Read(ref _readCount); + public int WriteCount => Volatile.Read(ref _writeCount); + + public ImmutablePoco Deserialize(ReadOnlySequence source) + { + Interlocked.Increment(ref _readCount); + var value = InbuiltTypeSerializer.DeserializeString(source); + if (value == "read fail") + { + throw new KnownFailureException("read failure"); + } + + return new ImmutablePoco(value); + } + + public void Serialize(ImmutablePoco value, IBufferWriter target) + { + Interlocked.Increment(ref _writeCount); + if (value.Value == "write fail") + { + throw new KnownFailureException("write failure"); + } + + InbuiltTypeSerializer.SerializeString(value.Value, target); + } + } } } diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs new file mode 100644 index 00000000000..ecb97ef3c7e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs @@ -0,0 +1,189 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.Tracing; +using System.Globalization; +using Microsoft.Extensions.Caching.Hybrid.Internal; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public sealed class TestEventListener : EventListener +{ + // captures both event and counter data + + // this is used as a class fixture from HybridCacheEventSourceTests, because there + // seems to be some unpredictable behaviours if multiple event sources/listeners are + // casually created etc + private const double EventCounterIntervalSec = 0.25; + + private readonly List<(int id, string name, EventLevel level)> _events = []; + private readonly Dictionary _counters = []; + + private object SyncLock => _events; + + internal HybridCacheEventSource Source { get; } = new(); + + public TestEventListener Reset(bool resetCounters = true) + { + lock (SyncLock) + { + _events.Clear(); + _counters.Clear(); + + if (resetCounters) + { + Source.ResetCounters(); + } + } + + Assert.True(Source.IsEnabled(), "should report as enabled"); + + return this; + } + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (ReferenceEquals(eventSource, Source)) + { + var args = new Dictionary + { + ["EventCounterIntervalSec"] = EventCounterIntervalSec.ToString("G", CultureInfo.InvariantCulture), + }; + EnableEvents(Source, EventLevel.Verbose, EventKeywords.All, args); + } + + base.OnEventSourceCreated(eventSource); + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (ReferenceEquals(eventData.EventSource, Source)) + { + // capture counters/events + lock (SyncLock) + { + if (eventData.EventName == "EventCounters" + && eventData.Payload is { Count: > 0 }) + { + foreach (var payload in eventData.Payload) + { + if (payload is IDictionary map) + { + string? name = null; + string? displayName = null; + double? value = null; + bool isIncrement = false; + foreach (var pair in map) + { + switch (pair.Key) + { + case "Name" when pair.Value is string: + name = (string)pair.Value; + break; + case "DisplayName" when pair.Value is string s: + displayName = s; + break; + case "Mean": + isIncrement = false; + value = Convert.ToDouble(pair.Value); + break; + case "Increment": + isIncrement = true; + value = Convert.ToDouble(pair.Value); + break; + } + } + + if (name is not null && value is not null) + { + if (isIncrement && _counters.TryGetValue(name, out var oldPair)) + { + value += oldPair.value; // treat as delta from old + } + + Debug.WriteLine($"{name}={value}"); + _counters[name] = (displayName, value.Value); + } + } + } + } + else + { + _events.Add((eventData.EventId, eventData.EventName ?? "", eventData.Level)); + } + } + } + + base.OnEventWritten(eventData); + } + + public (int id, string name, EventLevel level) SingleEvent() + { + (int id, string name, EventLevel level) evt; + lock (SyncLock) + { + evt = Assert.Single(_events); + } + + return evt; + } + + public void AssertSingleEvent(int id, string name, EventLevel level) + { + var evt = SingleEvent(); + Assert.Equal(name, evt.name); + Assert.Equal(id, evt.id); + Assert.Equal(level, evt.level); + } + + public double AssertCounter(string name, string displayName) + { + lock (SyncLock) + { + Assert.True(_counters.TryGetValue(name, out var pair), $"counter not found: {name}"); + Assert.Equal(displayName, pair.displayName); + + _counters.Remove(name); // count as validated + return pair.value; + } + } + + public void AssertCounter(string name, string displayName, double expected) + { + var actual = AssertCounter(name, displayName); + if (!Equals(expected, actual)) + { + Assert.Fail($"{name}: expected {expected}, actual {actual}"); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Bug", "S1244:Floating point numbers should not be tested for equality", Justification = "Test expects exact zero")] + public void AssertRemainingCountersZero() + { + lock (SyncLock) + { + foreach (var pair in _counters) + { + if (pair.Value.value != 0) + { + Assert.Fail($"{pair.Key}: expected 0, actual {pair.Value.value}"); + } + } + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Clarity and usability")] + public async Task TryAwaitCountersAsync() + { + // allow 2 cycles because if we only allow 1, we run the risk of a + // snapshot being captured mid-cycle when we were setting up the test + // (ok, that's an unlikely race condition, but!) + await Task.Delay(TimeSpan.FromSeconds(EventCounterIntervalSec * 2)); + + lock (SyncLock) + { + return _counters.Count; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/UnreliableL2Tests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/UnreliableL2Tests.cs new file mode 100644 index 00000000000..7af85f9cba2 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/UnreliableL2Tests.cs @@ -0,0 +1,251 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +// validate HC stability when the L2 is unreliable +public class UnreliableL2Tests(ITestOutputHelper testLog) +{ + [Theory] + [InlineData(BreakType.None)] + [InlineData(BreakType.Synchronous, Log.IdCacheBackendWriteFailure)] + [InlineData(BreakType.Asynchronous, Log.IdCacheBackendWriteFailure)] + [InlineData(BreakType.AsynchronousYield, Log.IdCacheBackendWriteFailure)] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Intentional; tracking for out-of-band support only")] + public async Task WriteFailureInvisible(BreakType writeBreak, params int[] errorIds) + { + using (GetServices(out var hc, out var l1, out var l2, out var log)) + using (log) + { + // normal behaviour when working fine + var x = await hc.GetOrCreateAsync("x", NewGuid); + Assert.Equal(x, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.NotNull(l2.Tail.Get("x")); // exists + + l2.WriteBreak = writeBreak; + var y = await hc.GetOrCreateAsync("y", NewGuid); + Assert.Equal(y, await hc.GetOrCreateAsync("y", NewGuid)); + if (writeBreak == BreakType.None) + { + Assert.NotNull(l2.Tail.Get("y")); // exists + } + else + { + Assert.Null(l2.Tail.Get("y")); // does not exist + } + + await l2.LastWrite; // allows out-of-band write to complete + await Task.Delay(150); // even then: thread jitter can cause problems + + log.WriteTo(testLog); + log.AssertErrors(errorIds); + } + } + + [Theory] + [InlineData(BreakType.None)] + [InlineData(BreakType.Synchronous, Log.IdCacheBackendReadFailure, Log.IdCacheBackendReadFailure)] + [InlineData(BreakType.Asynchronous, Log.IdCacheBackendReadFailure, Log.IdCacheBackendReadFailure)] + [InlineData(BreakType.AsynchronousYield, Log.IdCacheBackendReadFailure, Log.IdCacheBackendReadFailure)] + public async Task ReadFailureInvisible(BreakType readBreak, params int[] errorIds) + { + using (GetServices(out var hc, out var l1, out var l2, out var log)) + using (log) + { + // create two new values via HC; this should go down to l2 + var x = await hc.GetOrCreateAsync("x", NewGuid); + var y = await hc.GetOrCreateAsync("y", NewGuid); + + // this should be reliable and repeatable + Assert.Equal(x, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.Equal(y, await hc.GetOrCreateAsync("y", NewGuid)); + + // even if we clean L1, causing new L2 fetches + l1.Clear(); + Assert.Equal(x, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.Equal(y, await hc.GetOrCreateAsync("y", NewGuid)); + + // now we break L2 in some predictable way, *without* clearing L1 - the + // values should still be available via L1 + l2.ReadBreak = readBreak; + Assert.Equal(x, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.Equal(y, await hc.GetOrCreateAsync("y", NewGuid)); + + // but if we clear L1 to force L2 hits, we anticipate problems + l1.Clear(); + if (readBreak == BreakType.None) + { + Assert.Equal(x, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.Equal(y, await hc.GetOrCreateAsync("y", NewGuid)); + } + else + { + // because L2 is unavailable and L1 is empty, we expect the callback + // to be used again, generating new values + var a = await hc.GetOrCreateAsync("x", NewGuid, NoL2Write); + var b = await hc.GetOrCreateAsync("y", NewGuid, NoL2Write); + + Assert.NotEqual(x, a); + Assert.NotEqual(y, b); + + // but those *new* values are at least reliable inside L1 + Assert.Equal(a, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.Equal(b, await hc.GetOrCreateAsync("y", NewGuid)); + } + + log.WriteTo(testLog); + log.AssertErrors(errorIds); + } + } + + private static HybridCacheEntryOptions NoL2Write { get; } = new HybridCacheEntryOptions { Flags = HybridCacheEntryFlags.DisableDistributedCacheWrite }; + + public enum BreakType + { + None, // async API works correctly + Synchronous, // async API faults directly rather than return a faulted task + Asynchronous, // async API returns a completed asynchronous fault + AsynchronousYield, // async API returns an incomplete asynchronous fault + } + + private static ValueTask NewGuid(CancellationToken cancellationToken) => new(Guid.NewGuid()); + + private static IDisposable GetServices(out HybridCache hc, out MemoryCache l1, + out UnreliableDistributedCache l2, out LogCollector log) + { + // we need an entirely separate MC for the dummy backend, not connected to our + // "real" services + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + var backend = services.BuildServiceProvider().GetRequiredService(); + + // now create the "real" services + l2 = new UnreliableDistributedCache(backend); + var collector = new LogCollector(); + log = collector; + services = new ServiceCollection(); + services.AddSingleton(l2); + services.AddHybridCache(); + services.AddLogging(options => + { + options.ClearProviders(); + options.AddProvider(collector); + }); + var lifetime = services.BuildServiceProvider(); + hc = lifetime.GetRequiredService(); + l1 = Assert.IsType(lifetime.GetRequiredService()); + return lifetime; + } + + private sealed class UnreliableDistributedCache : IDistributedCache + { + public UnreliableDistributedCache(IDistributedCache tail) + { + Tail = tail; + } + + public IDistributedCache Tail { get; } + public BreakType ReadBreak { get; set; } + public BreakType WriteBreak { get; set; } + + public Task LastWrite { get; private set; } = Task.CompletedTask; + + public byte[]? Get(string key) => throw new NotSupportedException(); // only async API in use + + public Task GetAsync(string key, CancellationToken token = default) + => TrackLast(ThrowIfBrokenAsync(ReadBreak) ?? Tail.GetAsync(key, token)); + + public void Refresh(string key) => throw new NotSupportedException(); // only async API in use + + public Task RefreshAsync(string key, CancellationToken token = default) + => TrackLast(ThrowIfBrokenAsync(WriteBreak) ?? Tail.RefreshAsync(key, token)); + + public void Remove(string key) => throw new NotSupportedException(); // only async API in use + + public Task RemoveAsync(string key, CancellationToken token = default) + => TrackLast(ThrowIfBrokenAsync(WriteBreak) ?? Tail.RemoveAsync(key, token)); + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => throw new NotSupportedException(); // only async API in use + + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + => TrackLast(ThrowIfBrokenAsync(WriteBreak) ?? Tail.SetAsync(key, value, options, token)); + + [DoesNotReturn] + private static void Throw() => throw new IOException("L2 offline"); + + private static async Task ThrowAsync(bool yield) + { + if (yield) + { + await Task.Yield(); + } + + Throw(); + return default; // never reached + } + + private static Task? ThrowIfBrokenAsync(BreakType breakType) => ThrowIfBrokenAsync(breakType); + + [SuppressMessage("Critical Bug", "S4586:Non-async \"Task/Task\" methods should not return null", Justification = "Intentional for propagation")] + private static Task? ThrowIfBrokenAsync(BreakType breakType) + { + switch (breakType) + { + case BreakType.Asynchronous: + return ThrowAsync(false); + case BreakType.AsynchronousYield: + return ThrowAsync(true); + case BreakType.None: + return null; + default: + // includes BreakType.Synchronous and anything unknown + Throw(); + break; + } + + return null; + } + + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Intentional; tracking for out-of-band support only")] + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "We don't need the failure type - just the timing")] + private static Task IgnoreFailure(Task task) + { + return task.Status == TaskStatus.RanToCompletion + ? Task.CompletedTask : IgnoreAsync(task); + + static async Task IgnoreAsync(Task task) + { + try + { + await task; + } + catch + { + // we only care about the "when"; failure is fine + } + } + } + + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Intentional; tracking for out-of-band support only")] + private Task TrackLast(Task lastWrite) + { + LastWrite = IgnoreFailure(lastWrite); + return lastWrite; + } + + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Intentional; tracking for out-of-band support only")] + private Task TrackLast(Task lastWrite) + { + LastWrite = IgnoreFailure(lastWrite); + return lastWrite; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Abstractions.Tests/Microsoft.Extensions.Telemetry.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.Telemetry.Abstractions.Tests/Microsoft.Extensions.Telemetry.Abstractions.Tests.csproj index ac284fee861..387cec3c5c0 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Abstractions.Tests/Microsoft.Extensions.Telemetry.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Abstractions.Tests/Microsoft.Extensions.Telemetry.Abstractions.Tests.csproj @@ -12,4 +12,8 @@ + + + + From 7e59b8b4a23c4159deb6e03ea04a2cd604f08588 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 25 Oct 2024 23:10:16 -0400 Subject: [PATCH 081/190] Add NativeAOT testapp project for M.E.AI (#5573) * Add NativeAOT testapp project for M.E.AI * Address PR feedback --- .../JsonContext.cs | 4 +- .../OpenAIChatClient.cs | 56 +++++++++++++++++-- .../Utilities/AIJsonUtilities.Defaults.cs | 4 +- ...ensions.AI.AotCompatibility.TestApp.csproj | 26 +++++++++ .../Program.cs | 22 ++++++++ 5 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj create mode 100644 test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Program.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs index 5576cbf134a..1e1dabffab7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs @@ -48,11 +48,11 @@ private static JsonSerializerOptions CreateDefaultToolJsonOptions() { // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, // and we want to be flexible in terms of what can be put into the various collections in the object model. - // Otherwise, use the source-generated options to enable Native AOT. + // Otherwise, use the source-generated options to enable trimming and Native AOT. if (JsonSerializer.IsReflectionEnabledByDefault) { - // Keep in sync with the JsonSourceGenerationOptions on JsonContext below. + // Keep in sync with the JsonSourceGenerationOptions attribute on JsonContext above. JsonSerializerOptions options = new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver(), diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 935bb88f812..42851cdf62f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -3,11 +3,13 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -587,10 +589,9 @@ private sealed class OpenAIChatToolJson string? result = resultContent.Result as string; if (result is null && resultContent.Result is not null) { - JsonSerializerOptions options = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; try { - result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object))); + result = JsonSerializer.Serialize(resultContent.Result, JsonContext.GetTypeInfo(typeof(object), ToolCallJsonSerializerOptions)); } catch (NotSupportedException) { @@ -617,7 +618,9 @@ private sealed class OpenAIChatToolJson ChatToolCall.CreateFunctionToolCall( callRequest.CallId, callRequest.Name, - BinaryData.FromObjectAsJson(callRequest.Arguments, ToolCallJsonSerializerOptions))); + new(JsonSerializer.SerializeToUtf8Bytes( + callRequest.Arguments, + JsonContext.GetTypeInfo(typeof(IDictionary), ToolCallJsonSerializerOptions))))); } } @@ -670,8 +673,53 @@ private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8 argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); /// Source-generated JSON type information. + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] [JsonSerializable(typeof(OpenAIChatToolJson))] [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(JsonElement))] - private sealed partial class JsonContext : JsonSerializerContext; + private sealed partial class JsonContext : JsonSerializerContext + { + /// Gets the singleton used as the default in JSON serialization operations. + private static readonly JsonSerializerOptions _defaultToolJsonOptions = CreateDefaultToolJsonOptions(); + + /// Gets JSON type information for the specified type. + /// + /// This first tries to get the type information from , + /// falling back to if it can't. + /// + public static JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions? firstOptions) => + firstOptions?.TryGetTypeInfo(type, out JsonTypeInfo? info) is true ? + info : + _defaultToolJsonOptions.GetTypeInfo(type); + + /// Creates the default to use for serialization-related operations. + [UnconditionalSuppressMessage("AotAnalysis", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] + private static JsonSerializerOptions CreateDefaultToolJsonOptions() + { + // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, + // and we want to be flexible in terms of what can be put into the various collections in the object model. + // Otherwise, use the source-generated options to enable trimming and Native AOT. + + if (JsonSerializer.IsReflectionEnabledByDefault) + { + // Keep in sync with the JsonSourceGenerationOptions attribute on JsonContext above. + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + options.MakeReadOnly(); + return options; + } + + return Default.Options; + } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs index 94340160cb1..de2c2a695b6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs @@ -23,11 +23,11 @@ private static JsonSerializerOptions CreateDefaultOptions() { // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, // and we want to be flexible in terms of what can be put into the various collections in the object model. - // Otherwise, use the source-generated options to enable Native AOT. + // Otherwise, use the source-generated options to enable trimming and Native AOT. if (JsonSerializer.IsReflectionEnabledByDefault) { - // Keep in sync with the JsonSourceGenerationOptions on JsonContext below. + // Keep in sync with the JsonSourceGenerationOptions attribute on JsonContext below. JsonSerializerOptions options = new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver(), diff --git a/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj new file mode 100644 index 00000000000..183cd150937 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj @@ -0,0 +1,26 @@ + + + + Exe + $(LatestTargetFramework) + true + false + true + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Program.cs b/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Program.cs new file mode 100644 index 00000000000..b518dfa7739 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Program.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. + +#pragma warning disable S125 // Remove this commented out code + +using Microsoft.Extensions.AI; + +// Use types from each library. + +// Microsoft.Extensions.AI.Ollama +using var b = new OllamaChatClient("http://localhost:11434", "llama3.2"); + +// Microsoft.Extensions.AI.AzureAIInference +// using var a = new Azure.AI.Inference.ChatCompletionClient(new Uri("http://localhost"), new("apikey")); // uncomment once warnings in Azure.AI.Inference are addressed + +// Microsoft.Extensions.AI.OpenAI +// using var c = new OpenAI.OpenAIClient("apikey").AsChatClient("gpt-4o-mini"); // uncomment once warnings in OpenAI are addressed + +// Microsoft.Extensions.AI +AIFunctionFactory.Create(() => { }); + +System.Console.WriteLine("Success!"); From 500abd72bf893e33227261fb3f66399d2b60bdcb Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 26 Oct 2024 03:34:11 -0400 Subject: [PATCH 082/190] Add changelogs for M.E.AI projects (#5577) --- .../CHANGELOG.md | 19 +++++++++++++++++++ .../CHANGELOG.md | 12 ++++++++++++ .../CHANGELOG.md | 10 ++++++++++ .../CHANGELOG.md | 12 ++++++++++++ .../Microsoft.Extensions.AI/CHANGELOG.md | 17 +++++++++++++++++ 5 files changed, 70 insertions(+) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md create mode 100644 src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md create mode 100644 src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md create mode 100644 src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md new file mode 100644 index 00000000000..6b347a8c09d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -0,0 +1,19 @@ +# Release History + +## 9.0.0-preview.9.24525.1 + +- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. +- Annotated `FunctionCallContent.Exception` and `FunctionResultContent.Exception` as `[JsonIgnore]`, such that they're ignored when serializing instances with `JsonSerializer`. The corresponding constructors accepting an `Exception` were removed. +- Annotated `ChatCompletion.Message` as `[JsonIgnore]`, such that it's ignored when serializing instances with `JsonSerializer`. +- Added the `FunctionCallContent.CreateFromParsedArguments` method. +- Added the `AdditionalPropertiesDictionary.TryGetValue` method. +- Added the `StreamingChatCompletionUpdate.ModelId` property and removed the `AIContent.ModelId` property. +- Renamed the `GenerateAsync` extension method on `IEmbeddingGenerator<,>` to `GenerateEmbeddingsAsync` and updated it to return `Embedding` rather than `GeneratedEmbeddings`. +- Added `GenerateAndZipAsync` and `GenerateEmbeddingVectorAsync` extension methods for `IEmbeddingGenerator<,>`. +- Added the `EmbeddingGeneratorOptions.Dimensions` property. +- Added the `ChatOptions.TopK` property. +- Normalized `null` inputs in `TextContent` to be empty strings. + +## 9.0.0-preview.9.24507.7 + +Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md new file mode 100644 index 00000000000..7929cc7e8b2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md @@ -0,0 +1,12 @@ +# Release History + +## 9.0.0-preview.9.24525.1 + +- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. +- Updated to use Azure.AI.Inference 1.0.0-beta.2. +- Added `AzureAIInferenceEmbeddingGenerator` and corresponding `AsEmbeddingGenerator` extension method. +- Improved handling of assistant messages that include both text and function call content. + +## 9.0.0-preview.9.24507.7 + +Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md new file mode 100644 index 00000000000..ffb35814039 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md @@ -0,0 +1,10 @@ +# Release History + +## 9.0.0-preview.9.24525.1 + +- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. +- Added additional constructors to `OllamaChatClient` and `OllamaEmbeddingGenerator` that accept `string` endpoints, in addition to the existing ones accepting `Uri` endpoints. + +## 9.0.0-preview.9.24507.7 + +Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md new file mode 100644 index 00000000000..179da41a0b0 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -0,0 +1,12 @@ +# Release History + +## 9.0.0-preview.9.24525.1 + +- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. +- Improved handling of system messages that include multiple content items. +- Improved handling of assistant messages that include both text and function call content. +- Fixed handling of streaming updates containing empty payloads. + +## 9.0.0-preview.9.24507.7 + +Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md new file mode 100644 index 00000000000..e2dae2e6e37 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md @@ -0,0 +1,17 @@ +# Release History + +## 9.0.0-preview.9.24525.1 + +- Added new `AIJsonUtilities` and `AIJsonSchemaCreateOptions` classes. +- Made `AIFunctionFactory.Create` safe for use with Native AOT. +- Simplified the set of `AIFunctionFactory.Create` overloads. +- Changed the default for `FunctionInvokingChatClient.ConcurrentInvocation` from `true` to `false`. +- Improved the readability of JSON generated as part of logging. +- Fixed handling of generated JSON schema names when using arrays or generic types. +- Improved `CachingChatClient`'s coalescing of streaming updates, including reduced memory allocation and enhanced metadata propagation. +- Updated `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to conform to the latest 1.28.0 draft specification of the Semantic Conventions for Generative AI systems. +- Improved `CompleteAsync`'s structured output support to handle primitive types, enums, and arrays. + +## 9.0.0-preview.9.24507.7 + +Initial Preview From 2aa1535ded3bbd602853e05107034ca33b2af32a Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 26 Oct 2024 03:34:31 -0400 Subject: [PATCH 083/190] Explicitly reference System.Memory.Data in OpenAI/AzureAIInference projects (#5576) To ensure a recent version is used. --- .../Microsoft.Extensions.AI.AzureAIInference.csproj | 1 + .../Microsoft.Extensions.AI.OpenAI.csproj | 1 + .../Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs | 3 +-- 3 files changed, 3 insertions(+), 2 deletions(-) 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 bfd0b8ea90b..3f9489dbdc7 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 @@ -28,6 +28,7 @@ + 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 87dda461c50..67df978b7d4 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 @@ -25,6 +25,7 @@ + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 42851cdf62f..0562352feb6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -267,8 +267,7 @@ public async IAsyncEnumerable CompleteStreamingAs existing.CallId ??= toolCallUpdate.ToolCallId; existing.Name ??= toolCallUpdate.FunctionName; - if (toolCallUpdate.FunctionArgumentsUpdate is { } update && - !update.ToMemory().IsEmpty) // workaround for https://github.com/dotnet/runtime/issues/68262 in 6.0.0 package + if (toolCallUpdate.FunctionArgumentsUpdate is { } update && !update.ToMemory().IsEmpty) { _ = (existing.Arguments ??= new()).Append(update.ToString()); } From e90c1fa05e280032e01553d8a930aa8f0fc947cb Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 26 Oct 2024 03:34:44 -0400 Subject: [PATCH 084/190] Fix AzureAIInferenceEmbeddingGenerator to respect EmbeddingGenerationOptions.Dimensions (#5575) Merge conflict blip. --- .../AzureAIInferenceEmbeddingGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs index 84198e6b2cc..866e55ad87a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs @@ -156,7 +156,7 @@ private EmbeddingsOptions ToAzureAIOptions(IEnumerable inputs, Embedding { EmbeddingsOptions result = new(inputs) { - Dimensions = _dimensions, + Dimensions = options?.Dimensions ?? _dimensions, Model = options?.ModelId ?? Metadata.ModelId, EncodingFormat = format, }; From 7f60bea1063c1095dace81b6e72c92dccf84ad44 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 30 Oct 2024 14:40:45 +0000 Subject: [PATCH 085/190] fix exception when generating boolean schemas (#5585) --- .../Utilities/AIJsonUtilities.Schema.cs | 18 ++++++++---------- .../AIJsonUtilitiesTests.cs | 7 +++++++ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs index 46fe45342f2..eb8f0d52a07 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs @@ -138,8 +138,6 @@ public static JsonElement CreateJsonSchema( JsonSerializerOptions? serializerOptions = null, AIJsonSchemaCreateOptions? inferenceOptions = null) { - _ = Throw.IfNull(serializerOptions); - serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; @@ -278,24 +276,24 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) { objSchema.Add(AdditionalPropertiesPropertyName, (JsonNode)false); } - } - - if (ctx.Path.IsEmpty) - { - // We are at the root-level schema node, update/append parameter-specific metadata // 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. // STJ represents .NET integer types as ["string", "integer"], which will then lead to an error. - if (TypeIsArrayContainingInteger(schema)) + if (TypeIsArrayContainingInteger(objSchema)) { // We don't want to emit any array for "type". In this case we know it contains "integer" // so reduce the type to that alone, assuming it's the most specific type. - // This makes schemas for Int32 (etc) work with Ollama + // This makes schemas for Int32 (etc) work with Ollama. JsonObject obj = ConvertSchemaToObject(ref schema); obj[TypePropertyName] = "integer"; _ = obj.Remove(PatternPropertyName); } + } + + if (ctx.Path.IsEmpty) + { + // We are at the root-level schema node, update/append parameter-specific metadata if (!string.IsNullOrWhiteSpace(key.Description)) { @@ -354,7 +352,7 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) } } - private static bool TypeIsArrayContainingInteger(JsonNode schema) + private static bool TypeIsArrayContainingInteger(JsonObject schema) { if (schema["type"] is JsonArray typeArray) { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs index db482d26804..d7ff5c6783e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs @@ -158,4 +158,11 @@ public enum MyEnumValue A = 1, B = 2 } + + [Fact] + public static void ResolveJsonSchema_CanBeBoolean() + { + JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(object)); + Assert.Equal(JsonValueKind.True, schema.ValueKind); + } } From fe9e5bf9a964d33fa8ebd61c57d6abe170f098e8 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 30 Oct 2024 13:34:55 -0400 Subject: [PATCH 086/190] Add ImageContent integration test (#5586) --- .../ChatClientIntegrationTests.cs | 31 ++++++++++++++++++ ...oft.Extensions.AI.Integration.Tests.csproj | 4 +++ .../dotnet.png | Bin 0 -> 2140 bytes .../OllamaChatClientIntegrationTests.cs | 2 ++ 4 files changed, 37 insertions(+) create mode 100644 test/Libraries/Microsoft.Extensions.AI.Integration.Tests/dotnet.png diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 0863e31db37..e9c2bd57d65 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; @@ -132,6 +133,27 @@ public virtual async Task CompleteStreamingAsync_UsageDataAvailable() Assert.Equal(usage.Details.InputTokenCount + usage.Details.OutputTokenCount, usage.Details.TotalTokenCount); } + protected virtual string? GetModel_MultiModal_DescribeImage() => null; + + [ConditionalFact] + public virtual async Task MultiModal_DescribeImage() + { + SkipIfNotEnabled(); + + var response = await _chatClient.CompleteAsync( + [ + new(ChatRole.User, + [ + new TextContent("What does this logo say?"), + new ImageContent(GetImageDataUri()), + ]) + ], + new() { ModelId = GetModel_MultiModal_DescribeImage() }); + + Assert.Single(response.Choices); + Assert.True(response.Message.Text?.IndexOf("net", StringComparison.OrdinalIgnoreCase) >= 0, response.Message.Text); + } + [ConditionalFact] public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_Parameterless() { @@ -714,6 +736,15 @@ private enum JobType Unknown, } + private static Uri GetImageDataUri() + { + using Stream? s = typeof(ChatClientIntegrationTests).Assembly.GetManifestResourceStream("Microsoft.Extensions.AI.dotnet.png"); + Assert.NotNull(s); + MemoryStream ms = new(); + s.CopyTo(ms); + return new Uri($"data:image/png;base64,{Convert.ToBase64String(ms.ToArray())}"); + } + [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 e38ccd3268b..04d9bc6d29f 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 @@ -15,6 +15,10 @@ true + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/dotnet.png b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/dotnet.png new file mode 100644 index 0000000000000000000000000000000000000000..fb00ecf91e4b78804c636194bb323bf3710fa1c6 GIT binary patch literal 2140 zcmeHIX;4#F6iz}QBp3(*v25~!F*qzP0ojoU1Wja7KpD}12oa>Xrfece2to*M4{<@1 zP%%ms&>BGn2e5_86S0m&0z?*tFd77$B7y>vzNa&tPJedV>7UNLH{U(qJ@=gNJNL&G zZwy{XCYg~i7z~-iW`$xfSQ!0vwGifWRzWuc0UHB1`G?p&M?Q^4^TQdnylqlFJQMHV zlMy}8cyoMm;xpH^t5o#5@y2-k+Mdkle$k#mS^4OrhKW<@s@vsbjW^%%!+QI>rn#<) z{_bgV=B_HFEH)`LIBec*h>(fF5SlnFpG|4X(PvmP2D6~~`@Sai?5+nm4-!M>!#e`& z78+VFVXe(SMlq!^eg7xWd6boUvM4P&8=5T0wgi! zp<$6Qus?iG)+d;(0n8C!5))oCLL24Oym*Gn(Wz_qt3FCV776EaQ0@8?Z;*X?j^|Sm zn!Z?CE$ZK^Bel^@7%D^$=pOWDB0`L52FJhnR;@@*4VX&YV4t-$6D&!6S(50;t4kpO zSu@uc7lG;JP{4;;gTKszM|=8RA1W@lCalj952n{cQ<2H{5Ho%XwCWD_{Z4eMJK>$z zk!wqDgEouoAc>PS^6@?wtqyq}w+)qQ-k}&oy@>2Jq0>$(&(2yz!kQ$B-aeP1JVAwi zLdDN$6HyHca*;h+GLwCO>;wmB&|!(*}Njk+((AJ3{PGOdx_ow6Z(=O$zl0DfhA9qWN%*0384ZX-%PdlCVsU;^WuLWS6 zFXEf8|L9P?|BxkhKeqYq++65zuqrIXB_=k7X0>One(W+8@jAr|KOqE zA{8rpb%xTK*LyH?wH7r3VwmvsXBR83fP-{+TLwEzY04n+9E2=$_+^Svj1xydpdM_| zOAWL@lsIQ|g;s%d*cN6$i7RV!TL3t`Nippk%z?;2>&ui}v9{Qry++zPRCad)IAFrl zVrN|@85!_xg;Kwn%BW~--xx!>Wk=-d&Bet~zEI_-Jkz#l_a=BNo+EN{T-sGgLK>2R zTl1?679M`Te?VAqflxs-Xlp(?+z^mfcBjCASoPc0Eo+$1Ukv);wnZsCeH&`cr6V+- zlv98Q2P&n*!Bn0NQC5WS;Rxq;oYdzbZwp4})3$-jUb&QY)+bmNVpr+``XIZdFn@{R z-y&lERCUNMmld3Uk>W<<`>Kw>#6lx$oBxuCIFtk;kF{VWJb!KMr(kW0zjXo2SiFoH kLN8~t3iGWE|8=58=$f)MBl>~e*<^I~9RFa}4c} 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() { From cd9da61b84166540907a938460df3653a2d6eea6 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 31 Oct 2024 10:33:56 -0400 Subject: [PATCH 087/190] Add ChatOptions.Seed (#5587) --- .../ChatCompletion/ChatOptions.cs | 4 ++++ .../AzureAIInferenceChatClient.cs | 6 +----- .../Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs | 6 +++++- .../Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs | 10 +++------- .../ChatCompletion/OpenTelemetryChatClient.cs | 2 +- .../ChatCompletion/ChatOptionsTests.cs | 7 +++++++ .../AzureAIInferenceChatClientTests.cs | 6 +++--- .../OllamaChatClientIntegrationTests.cs | 4 ++-- .../OllamaChatClientTests.cs | 2 +- .../OpenAIChatClientTests.cs | 2 +- 10 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index 4edbed900b4..0a4f6f58296 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -27,6 +27,9 @@ public class ChatOptions /// Gets or sets the presence penalty for generating chat responses. public float? PresencePenalty { get; set; } + /// Gets or sets a seed value used by a service to control the reproducability of results. + public long? Seed { get; set; } + /// /// Gets or sets the response format for the chat request. /// @@ -74,6 +77,7 @@ public virtual ChatOptions Clone() TopK = TopK, FrequencyPenalty = FrequencyPenalty, PresencePenalty = PresencePenalty, + Seed = Seed, ResponseFormat = ResponseFormat, ModelId = ModelId, ToolMode = ToolMode, diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index ecc41140b27..ba76f5c3c90 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -285,6 +285,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, result.NucleusSamplingFactor = options.TopP; result.PresencePenalty = options.PresencePenalty; result.Temperature = options.Temperature; + result.Seed = options.Seed; if (options.StopSequences is { Count: > 0 } stopSequences) { @@ -306,11 +307,6 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, { switch (prop.Key) { - // These properties are strongly-typed on the ChatCompletionsOptions class but not on the ChatOptions class. - case nameof(result.Seed) when prop.Value is long seed: - result.Seed = seed; - break; - // Propagate everything else to the ChatCompletionOptions' AdditionalProperties. default: if (prop.Value is not null) diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 72ddb13b2ac..18ff5d50b7c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -273,7 +273,6 @@ private OllamaChatRequest ToOllamaChatRequest(IList chatMessages, C 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.seed), (options, value) => options.seed = 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); @@ -314,6 +313,11 @@ private OllamaChatRequest ToOllamaChatRequest(IList chatMessages, C { (request.Options ??= new()).top_k = topK; } + + if (options.Seed is long seed) + { + (request.Options ??= new()).seed = seed; + } } return request; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 0562352feb6..985060256f7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -392,6 +392,9 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.TopP = options.TopP; result.PresencePenalty = options.PresencePenalty; result.Temperature = options.Temperature; +#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) { @@ -426,13 +429,6 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.AllowParallelToolCalls = allowParallelToolCalls; } -#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. - if (additionalProperties.TryGetValue(nameof(result.Seed), out long seed)) - { - result.Seed = seed; - } -#pragma warning restore OPENAI001 - if (additionalProperties.TryGetValue(nameof(result.TopLogProbabilityCount), out int topLogProbabilityCountInt)) { result.TopLogProbabilityCount = topLogProbabilityCountInt; diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index 905e756e246..a6dfe53adf5 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -322,7 +322,7 @@ private static ChatCompletion ComposeStreamingUpdatesIntoChatCompletion( _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.PerProvider(_system, "response_format"), responseFormat); } - if (options.AdditionalProperties?.TryGetValue("seed", out long seed) is true) + if (options.Seed is long seed) { _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.PerProvider(_system, "seed"), seed); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs index f83169712c3..fcd40a2f446 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs @@ -19,6 +19,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(options.TopK); Assert.Null(options.FrequencyPenalty); Assert.Null(options.PresencePenalty); + Assert.Null(options.Seed); Assert.Null(options.ResponseFormat); Assert.Null(options.ModelId); Assert.Null(options.StopSequences); @@ -33,6 +34,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(clone.TopK); Assert.Null(clone.FrequencyPenalty); Assert.Null(clone.PresencePenalty); + Assert.Null(options.Seed); Assert.Null(clone.ResponseFormat); Assert.Null(clone.ModelId); Assert.Null(clone.StopSequences); @@ -69,6 +71,7 @@ public void Properties_Roundtrip() options.TopK = 42; options.FrequencyPenalty = 0.4f; options.PresencePenalty = 0.5f; + options.Seed = 12345; options.ResponseFormat = ChatResponseFormat.Json; options.ModelId = "modelId"; options.StopSequences = stopSequences; @@ -82,6 +85,7 @@ public void Properties_Roundtrip() Assert.Equal(42, options.TopK); Assert.Equal(0.4f, options.FrequencyPenalty); Assert.Equal(0.5f, options.PresencePenalty); + Assert.Equal(12345, options.Seed); Assert.Same(ChatResponseFormat.Json, options.ResponseFormat); Assert.Equal("modelId", options.ModelId); Assert.Same(stopSequences, options.StopSequences); @@ -96,6 +100,7 @@ public void Properties_Roundtrip() Assert.Equal(42, clone.TopK); Assert.Equal(0.4f, clone.FrequencyPenalty); Assert.Equal(0.5f, clone.PresencePenalty); + Assert.Equal(12345, options.Seed); Assert.Same(ChatResponseFormat.Json, clone.ResponseFormat); Assert.Equal("modelId", clone.ModelId); Assert.Equal(stopSequences, clone.StopSequences); @@ -126,6 +131,7 @@ public void JsonSerialization_Roundtrips() options.TopK = 42; options.FrequencyPenalty = 0.4f; options.PresencePenalty = 0.5f; + options.Seed = 12345; options.ResponseFormat = ChatResponseFormat.Json; options.ModelId = "modelId"; options.StopSequences = stopSequences; @@ -148,6 +154,7 @@ public void JsonSerialization_Roundtrips() Assert.Equal(42, deserialized.TopK); Assert.Equal(0.4f, deserialized.FrequencyPenalty); Assert.Equal(0.5f, deserialized.PresencePenalty); + Assert.Equal(12345, deserialized.Seed); Assert.Equal(ChatResponseFormat.Json, deserialized.ResponseFormat); Assert.NotSame(ChatResponseFormat.Json, deserialized.ResponseFormat); Assert.Equal("modelId", deserialized.ModelId); diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 4fb5122cc93..f404f5e61ef 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -247,8 +247,8 @@ public async Task MultipleMessages_NonStreaming() ], "presence_penalty": 0.5, "frequency_penalty": 0.75, - "model": "gpt-4o-mini", - "seed": 42 + "seed": 42, + "model": "gpt-4o-mini" } """; @@ -303,7 +303,7 @@ public async Task MultipleMessages_NonStreaming() FrequencyPenalty = 0.75f, PresencePenalty = 0.5f, StopSequences = ["great"], - AdditionalProperties = new() { ["seed"] = 42L }, + Seed = 42, }); Assert.NotNull(response); diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs index ac941623124..4c71690baaf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs @@ -49,7 +49,7 @@ public async Task PromptBasedFunctionCalling_NoArgs() ModelId = "llama3:8b", Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")], Temperature = 0, - AdditionalProperties = new() { ["seed"] = 0L }, + Seed = 0, }); Assert.Single(response.Choices); @@ -83,7 +83,7 @@ public async Task PromptBasedFunctionCalling_WithArgs() { Tools = [stockPriceTool, irrelevantTool], Temperature = 0, - AdditionalProperties = new() { ["seed"] = 0L }, + Seed = 0, }); Assert.Single(response.Choices); diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs index 3e281173c8b..67b10e3f24b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs @@ -254,7 +254,7 @@ public async Task MultipleMessages_NonStreaming() FrequencyPenalty = 0.75f, PresencePenalty = 0.5f, StopSequences = ["great"], - AdditionalProperties = new() { ["seed"] = 42 }, + Seed = 42, }); Assert.NotNull(response); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 691804e5fb8..05d2f5a22ff 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -348,7 +348,7 @@ public async Task MultipleMessages_NonStreaming() FrequencyPenalty = 0.75f, PresencePenalty = 0.5f, StopSequences = ["great"], - AdditionalProperties = new() { ["seed"] = 42 }, + Seed = 42, }); Assert.NotNull(response); From 365f33ce1f5f627f4e14d2099cc6159967f6a874 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 31 Oct 2024 14:34:50 +0000 Subject: [PATCH 088/190] Lower `AIJsonUtilities` to STJv8 and move to Abstractions library. (#5582) * Lower AIJsonUtilities to STJv8 and move to Abstractions library. * Add README.md --- eng/MSBuild/LegacySupport.props | 4 + eng/packages/TestOnly.props | 2 + ...icrosoft.Extensions.AI.Abstractions.csproj | 1 + .../Utilities/AIJsonSchemaCreateOptions.cs | 0 .../Utilities/AIJsonUtilities.Defaults.cs | 0 .../Utilities/AIJsonUtilities.Schema.cs | 85 +- .../JsonSchemaExporter.JsonSchema.cs | 545 +++++++ .../JsonSchemaExporter/JsonSchemaExporter.cs | 1128 ++++++++++++++ .../JsonSchemaExporterContext.cs | 77 + .../JsonSchemaExporterOptions.cs | 38 + .../NullabilityInfoContext/NullabilityInfo.cs | 75 + .../NullabilityInfoContext.cs | 661 +++++++++ .../NullabilityInfoHelpers.cs | 47 + src/Shared/JsonSchemaExporter/README.md | 11 + src/Shared/Shared.csproj | 6 +- test/Shared/JsonSchemaExporter/Helpers.cs | 91 ++ .../JsonSchemaExporterConfigurationTests.cs | 35 + .../JsonSchemaExporterTests.cs | 148 ++ test/Shared/JsonSchemaExporter/TestData.cs | 55 + test/Shared/JsonSchemaExporter/TestTypes.cs | 1293 +++++++++++++++++ test/Shared/Shared.Tests.csproj | 9 +- 21 files changed, 4294 insertions(+), 17 deletions(-) rename src/Libraries/{Microsoft.Extensions.AI => Microsoft.Extensions.AI.Abstractions}/Utilities/AIJsonSchemaCreateOptions.cs (100%) rename src/Libraries/{Microsoft.Extensions.AI => Microsoft.Extensions.AI.Abstractions}/Utilities/AIJsonUtilities.Defaults.cs (100%) rename src/Libraries/{Microsoft.Extensions.AI => Microsoft.Extensions.AI.Abstractions}/Utilities/AIJsonUtilities.Schema.cs (85%) create mode 100644 src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs create mode 100644 src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs create mode 100644 src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs create mode 100644 src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs create mode 100644 src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs create mode 100644 src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs create mode 100644 src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs create mode 100644 src/Shared/JsonSchemaExporter/README.md create mode 100644 test/Shared/JsonSchemaExporter/Helpers.cs create mode 100644 test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs create mode 100644 test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs create mode 100644 test/Shared/JsonSchemaExporter/TestData.cs create mode 100644 test/Shared/JsonSchemaExporter/TestTypes.cs diff --git a/eng/MSBuild/LegacySupport.props b/eng/MSBuild/LegacySupport.props index 2cfe7b73964..842951ab867 100644 --- a/eng/MSBuild/LegacySupport.props +++ b/eng/MSBuild/LegacySupport.props @@ -43,6 +43,10 @@ + + + + diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 2bde3b34e05..78772d87d09 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -20,6 +20,8 @@ + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index bb1a3b63708..30d5cd84425 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -19,6 +19,7 @@ + true true true true diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonSchemaCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonSchemaCreateOptions.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Defaults.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs similarity index 85% rename from src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs index eb8f0d52a07..cd33a2557af 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs @@ -5,6 +5,9 @@ using System.Collections.Concurrent; using System.ComponentModel; using System.Diagnostics; +#if !NET9_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -16,6 +19,7 @@ #pragma warning disable S1121 // Assignments should not be made from within sub-expressions #pragma warning disable S107 // Methods should not have too many parameters #pragma warning disable S1075 // URIs should not be hardcoded +#pragma warning disable SA1118 // Parameter should not span multiple lines using FunctionParameterKey = ( System.Type? Type, @@ -174,6 +178,11 @@ private static JsonElement GetJsonSchemaCached(JsonSerializerOptions options, Fu #endif } +#if !NET9_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", + Justification = "Pre STJ-9 schema extraction can fail with a runtime exception if certain reflection metadata have been trimmed. " + + "The exception message will guide users to turn off 'IlcTrimMetadata' which resolves all issues.")] +#endif private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, FunctionParameterKey key) { _ = Throw.IfNull(options); @@ -236,16 +245,9 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) const string DefaultPropertyName = "default"; const string RefPropertyName = "$ref"; - // Find the first DescriptionAttribute, starting first from the property, then the parameter, and finally the type itself. - Type descAttrType = typeof(DescriptionAttribute); - var descriptionAttribute = - GetAttrs(descAttrType, ctx.PropertyInfo?.AttributeProvider)?.FirstOrDefault() ?? - GetAttrs(descAttrType, ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider)?.FirstOrDefault() ?? - GetAttrs(descAttrType, ctx.TypeInfo.Type)?.FirstOrDefault(); - - if (descriptionAttribute is DescriptionAttribute attr) + if (ctx.ResolveAttribute() is { } attr) { - ConvertSchemaToObject(ref schema).Insert(0, DescriptionPropertyName, (JsonNode)attr.Description); + ConvertSchemaToObject(ref schema).InsertAtStart(DescriptionPropertyName, (JsonNode)attr.Description); } if (schema is JsonObject objSchema) @@ -268,7 +270,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) // Include the type keyword in enum types if (key.IncludeTypeInEnumSchemas && ctx.TypeInfo.Type.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) { - objSchema.Insert(0, TypePropertyName, "string"); + objSchema.InsertAtStart(TypePropertyName, "string"); } // Disallow additional properties in object schemas @@ -303,7 +305,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) if (index < 0) { // If there's no description property, insert it at the beginning of the doc. - obj.Insert(0, DescriptionPropertyName, (JsonNode)key.Description!); + obj.InsertAtStart(DescriptionPropertyName, (JsonNode)key.Description!); } else { @@ -321,15 +323,12 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) if (key.IncludeSchemaUri) { // The $schema property must be the first keyword in the object - ConvertSchemaToObject(ref schema).Insert(0, SchemaPropertyName, (JsonNode)SchemaKeywordUri); + ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); } } return schema; - static object[]? GetAttrs(Type attrType, ICustomAttributeProvider? provider) => - provider?.GetCustomAttributes(attrType, inherit: false); - static JsonObject ConvertSchemaToObject(ref JsonNode schema) { JsonObject obj; @@ -368,6 +367,62 @@ private static bool TypeIsArrayContainingInteger(JsonObject schema) return false; } + private static void InsertAtStart(this JsonObject jsonObject, string key, JsonNode value) + { +#if NET9_0_OR_GREATER + jsonObject.Insert(0, key, value); +#else + jsonObject.Remove(key); + var copiedEntries = jsonObject.ToArray(); + jsonObject.Clear(); + + jsonObject.Add(key, value); + foreach (var entry in copiedEntries) + { + jsonObject[entry.Key] = entry.Value; + } +#endif + } + +#if !NET9_0_OR_GREATER + private static int IndexOf(this JsonObject jsonObject, string key) + { + int i = 0; + foreach (var entry in jsonObject) + { + if (string.Equals(entry.Key, key, StringComparison.Ordinal)) + { + return i; + } + + i++; + } + + return -1; + } +#endif + + 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 +#if NET9_0_OR_GREATER + GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? + GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? +#else + GetAttrs(ctx.PropertyAttributeProvider) ?? + GetAttrs(ctx.ParameterInfo) ?? +#endif + GetAttrs(ctx.TypeInfo.Type); + + static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => + (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); + } + private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) { Utf8JsonReader reader = new(utf8Json); diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs new file mode 100644 index 00000000000..0f1044fc6eb --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs @@ -0,0 +1,545 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET9_0_OR_GREATER +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json.Nodes; + +namespace System.Text.Json.Schema; + +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable S1144 // Unused private types or members should be removed + +internal static partial class JsonSchemaExporter +{ + // Simple JSON schema representation taken from System.Text.Json + // https://github.com/dotnet/runtime/blob/50d6cad649aad2bfa4069268eddd16fd51ec5cf3/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchema.cs + private sealed class JsonSchema + { + public static JsonSchema False { get; } = new(false); + public static JsonSchema True { get; } = new(true); + + public JsonSchema() + { + } + + private JsonSchema(bool trueOrFalse) + { + _trueOrFalse = trueOrFalse; + } + + public bool IsTrue => _trueOrFalse is true; + public bool IsFalse => _trueOrFalse is false; + private readonly bool? _trueOrFalse; + + public string? Schema + { + get => _schema; + set + { + VerifyMutable(); + _schema = value; + } + } + + private string? _schema; + + public string? Title + { + get => _title; + set + { + VerifyMutable(); + _title = value; + } + } + + private string? _title; + + public string? Description + { + get => _description; + set + { + VerifyMutable(); + _description = value; + } + } + + private string? _description; + + public string? Ref + { + get => _ref; + set + { + VerifyMutable(); + _ref = value; + } + } + + private string? _ref; + + public string? Comment + { + get => _comment; + set + { + VerifyMutable(); + _comment = value; + } + } + + private string? _comment; + + public JsonSchemaType Type + { + get => _type; + set + { + VerifyMutable(); + _type = value; + } + } + + private JsonSchemaType _type = JsonSchemaType.Any; + + public string? Format + { + get => _format; + set + { + VerifyMutable(); + _format = value; + } + } + + private string? _format; + + public string? Pattern + { + get => _pattern; + set + { + VerifyMutable(); + _pattern = value; + } + } + + private string? _pattern; + + public JsonNode? Constant + { + get => _constant; + set + { + VerifyMutable(); + _constant = value; + } + } + + private JsonNode? _constant; + + public List>? Properties + { + get => _properties; + set + { + VerifyMutable(); + _properties = value; + } + } + + private List>? _properties; + + public List? Required + { + get => _required; + set + { + VerifyMutable(); + _required = value; + } + } + + private List? _required; + + public JsonSchema? Items + { + get => _items; + set + { + VerifyMutable(); + _items = value; + } + } + + private JsonSchema? _items; + + public JsonSchema? AdditionalProperties + { + get => _additionalProperties; + set + { + VerifyMutable(); + _additionalProperties = value; + } + } + + private JsonSchema? _additionalProperties; + + public JsonArray? Enum + { + get => _enum; + set + { + VerifyMutable(); + _enum = value; + } + } + + private JsonArray? _enum; + + public JsonSchema? Not + { + get => _not; + set + { + VerifyMutable(); + _not = value; + } + } + + private JsonSchema? _not; + + public List? AnyOf + { + get => _anyOf; + set + { + VerifyMutable(); + _anyOf = value; + } + } + + private List? _anyOf; + + public bool HasDefaultValue + { + get => _hasDefaultValue; + set + { + VerifyMutable(); + _hasDefaultValue = value; + } + } + + private bool _hasDefaultValue; + + public JsonNode? DefaultValue + { + get => _defaultValue; + set + { + VerifyMutable(); + _defaultValue = value; + } + } + + private JsonNode? _defaultValue; + + public int? MinLength + { + get => _minLength; + set + { + VerifyMutable(); + _minLength = value; + } + } + + private int? _minLength; + + public int? MaxLength + { + get => _maxLength; + set + { + VerifyMutable(); + _maxLength = value; + } + } + + private int? _maxLength; + + public JsonSchemaExporterContext? GenerationContext { get; set; } + + public int KeywordCount + { + get + { + if (_trueOrFalse != null) + { + return 0; + } + + int count = 0; + Count(Schema != null); + Count(Ref != null); + Count(Comment != null); + Count(Title != null); + Count(Description != null); + Count(Type != JsonSchemaType.Any); + Count(Format != null); + Count(Pattern != null); + Count(Constant != null); + Count(Properties != null); + Count(Required != null); + Count(Items != null); + Count(AdditionalProperties != null); + Count(Enum != null); + Count(Not != null); + Count(AnyOf != null); + Count(HasDefaultValue); + Count(MinLength != null); + Count(MaxLength != null); + + return count; + + void Count(bool isKeywordSpecified) => count += isKeywordSpecified ? 1 : 0; + } + } + + public void MakeNullable() + { + if (_trueOrFalse != null) + { + return; + } + + if (Type != JsonSchemaType.Any) + { + Type |= JsonSchemaType.Null; + } + } + + public JsonNode ToJsonNode(JsonSchemaExporterOptions options) + { + if (_trueOrFalse is { } boolSchema) + { + return CompleteSchema((JsonNode)boolSchema); + } + + var objSchema = new JsonObject(); + + if (Schema != null) + { + objSchema.Add(JsonSchemaConstants.SchemaPropertyName, Schema); + } + + if (Title != null) + { + objSchema.Add(JsonSchemaConstants.TitlePropertyName, Title); + } + + if (Description != null) + { + objSchema.Add(JsonSchemaConstants.DescriptionPropertyName, Description); + } + + if (Ref != null) + { + objSchema.Add(JsonSchemaConstants.RefPropertyName, Ref); + } + + if (Comment != null) + { + objSchema.Add(JsonSchemaConstants.CommentPropertyName, Comment); + } + + if (MapSchemaType(Type) is JsonNode type) + { + objSchema.Add(JsonSchemaConstants.TypePropertyName, type); + } + + if (Format != null) + { + objSchema.Add(JsonSchemaConstants.FormatPropertyName, Format); + } + + if (Pattern != null) + { + objSchema.Add(JsonSchemaConstants.PatternPropertyName, Pattern); + } + + if (Constant != null) + { + objSchema.Add(JsonSchemaConstants.ConstPropertyName, Constant); + } + + if (Properties != null) + { + var properties = new JsonObject(); + foreach (KeyValuePair property in Properties) + { + properties.Add(property.Key, property.Value.ToJsonNode(options)); + } + + objSchema.Add(JsonSchemaConstants.PropertiesPropertyName, properties); + } + + if (Required != null) + { + var requiredArray = new JsonArray(); + foreach (string requiredProperty in Required) + { + requiredArray.Add((JsonNode)requiredProperty); + } + + objSchema.Add(JsonSchemaConstants.RequiredPropertyName, requiredArray); + } + + if (Items != null) + { + objSchema.Add(JsonSchemaConstants.ItemsPropertyName, Items.ToJsonNode(options)); + } + + if (AdditionalProperties != null) + { + objSchema.Add(JsonSchemaConstants.AdditionalPropertiesPropertyName, AdditionalProperties.ToJsonNode(options)); + } + + if (Enum != null) + { + objSchema.Add(JsonSchemaConstants.EnumPropertyName, Enum); + } + + if (Not != null) + { + objSchema.Add(JsonSchemaConstants.NotPropertyName, Not.ToJsonNode(options)); + } + + if (AnyOf != null) + { + JsonArray anyOfArray = new(); + foreach (JsonSchema schema in AnyOf) + { + anyOfArray.Add(schema.ToJsonNode(options)); + } + + objSchema.Add(JsonSchemaConstants.AnyOfPropertyName, anyOfArray); + } + + if (HasDefaultValue) + { + objSchema.Add(JsonSchemaConstants.DefaultPropertyName, DefaultValue); + } + + if (MinLength is int minLength) + { + objSchema.Add(JsonSchemaConstants.MinLengthPropertyName, (JsonNode)minLength); + } + + if (MaxLength is int maxLength) + { + objSchema.Add(JsonSchemaConstants.MaxLengthPropertyName, (JsonNode)maxLength); + } + + return CompleteSchema(objSchema); + + JsonNode CompleteSchema(JsonNode schema) + { + if (GenerationContext is { } context) + { + Debug.Assert(options.TransformSchemaNode != null, "context should only be populated if a callback is present."); + + // Apply any user-defined transformations to the schema. + return options.TransformSchemaNode!(context, schema); + } + + return schema; + } + } + + public static void EnsureMutable(ref JsonSchema schema) + { + switch (schema._trueOrFalse) + { + case false: + schema = new JsonSchema { Not = JsonSchema.True }; + break; + case true: + schema = new JsonSchema(); + break; + } + } + + private static readonly JsonSchemaType[] _schemaValues = new JsonSchemaType[] + { + // NB the order of these values influences order of types in the rendered schema + JsonSchemaType.String, + JsonSchemaType.Integer, + JsonSchemaType.Number, + JsonSchemaType.Boolean, + JsonSchemaType.Array, + JsonSchemaType.Object, + JsonSchemaType.Null, + }; + + private void VerifyMutable() + { + Debug.Assert(_trueOrFalse is null, "Schema is not mutable"); + } + + private static JsonNode? MapSchemaType(JsonSchemaType schemaType) + { + if (schemaType is JsonSchemaType.Any) + { + return null; + } + + if (ToIdentifier(schemaType) is string identifier) + { + return identifier; + } + + var array = new JsonArray(); + foreach (JsonSchemaType type in _schemaValues) + { + if ((schemaType & type) != 0) + { + array.Add((JsonNode)ToIdentifier(type)!); + } + } + + return array; + + static string? ToIdentifier(JsonSchemaType schemaType) => schemaType switch + { + JsonSchemaType.Null => "null", + JsonSchemaType.Boolean => "boolean", + JsonSchemaType.Integer => "integer", + JsonSchemaType.Number => "number", + JsonSchemaType.String => "string", + JsonSchemaType.Array => "array", + JsonSchemaType.Object => "object", + _ => null, + }; + } + } + + [Flags] + private enum JsonSchemaType + { + Any = 0, // No type declared on the schema + Null = 1, + Boolean = 2, + Integer = 4, + Number = 8, + String = 16, + Array = 32, + Object = 64, + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs new file mode 100644 index 00000000000..9c4b83f8343 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs @@ -0,0 +1,1128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET9_0_OR_GREATER +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reflection; +#if NET +using System.Runtime.InteropServices; +#endif +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable LA0002 // Use 'Microsoft.Shared.Text.NumericExtensions.ToInvariantString' for improved performance +#pragma warning disable S107 // Methods should not have too many parameters +#pragma warning disable S103 // Lines should not be too long +#pragma warning disable S1121 // Assignments should not be made from within sub-expressions +#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable S3358 // Ternary operators should not be nested +#pragma warning disable EA0004 // Make type internal since project is executable + +namespace System.Text.Json.Schema; + +/// +/// Maps .NET types to JSON schema objects using contract metadata from instances. +/// +#if !SHARED_PROJECT +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif +internal static partial class JsonSchemaExporter +{ + // Polyfill implementation of JsonSchemaExporter for System.Text.Json version 8.0.0. + // Uses private reflection to access metadata not available with the older APIs of STJ. + + private const string RequiresUnreferencedCodeMessage = + "Uses private reflection on System.Text.Json components to access converter metadata. " + + "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."; + + /// + /// Generates a JSON schema corresponding to the contract metadata of the specified type. + /// + /// The options instance from which to resolve the contract metadata. + /// The root type for which to generate the JSON schema. + /// The exporterOptions object controlling the schema generation. + /// A new instance defining the JSON schema for . + /// One of the specified parameters is . + /// The parameter contains unsupported exporterOptions. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + public static JsonNode GetJsonSchemaAsNode(this JsonSerializerOptions options, Type type, JsonSchemaExporterOptions? exporterOptions = null) + { + _ = Throw.IfNull(options); + _ = Throw.IfNull(type); + ValidateOptions(options); + + exporterOptions ??= JsonSchemaExporterOptions.Default; + JsonTypeInfo typeInfo = options.GetTypeInfo(type); + return MapRootTypeJsonSchema(typeInfo, exporterOptions); + } + + /// + /// Generates a JSON schema corresponding to the specified contract metadata. + /// + /// The contract metadata for which to generate the schema. + /// The exporterOptions object controlling the schema generation. + /// A new instance defining the JSON schema for . + /// One of the specified parameters is . + /// The parameter contains unsupported exporterOptions. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + public static JsonNode GetJsonSchemaAsNode(this JsonTypeInfo typeInfo, JsonSchemaExporterOptions? exporterOptions = null) + { + _ = Throw.IfNull(typeInfo); + ValidateOptions(typeInfo.Options); + + exporterOptions ??= JsonSchemaExporterOptions.Default; + return MapRootTypeJsonSchema(typeInfo, exporterOptions); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static JsonNode MapRootTypeJsonSchema(JsonTypeInfo typeInfo, JsonSchemaExporterOptions exporterOptions) + { + GenerationState state = new(exporterOptions, typeInfo.Options); + JsonSchema schema = MapJsonSchemaCore(ref state, typeInfo); + return schema.ToJsonNode(exporterOptions); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static JsonSchema MapJsonSchemaCore( + ref GenerationState state, + JsonTypeInfo typeInfo, + Type? parentType = null, + JsonPropertyInfo? propertyInfo = null, + ICustomAttributeProvider? propertyAttributeProvider = null, + ParameterInfo? parameterInfo = null, + bool isNonNullableType = false, + JsonConverter? customConverter = null, + JsonNumberHandling? customNumberHandling = null, + JsonTypeInfo? parentPolymorphicTypeInfo = null, + bool parentPolymorphicTypeContainsTypesWithoutDiscriminator = false, + bool parentPolymorphicTypeIsNonNullable = false, + KeyValuePair? typeDiscriminator = null, + bool cacheResult = true) + { + Debug.Assert(typeInfo.IsReadOnly, "The specified contract must have been made read-only."); + + JsonSchemaExporterContext exporterContext = state.CreateContext(typeInfo, parentPolymorphicTypeInfo, parentType, propertyInfo, parameterInfo, propertyAttributeProvider); + + if (cacheResult && typeInfo.Kind is not JsonTypeInfoKind.None && + state.TryGetExistingJsonPointer(exporterContext, out string? existingJsonPointer)) + { + // The schema context has already been generated in the schema document, return a reference to it. + return CompleteSchema(ref state, new JsonSchema { Ref = existingJsonPointer }); + } + + JsonSchema schema; + JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter; + JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Options.NumberHandling; + + if (!IsBuiltInConverter(effectiveConverter)) + { + // Return a `true` schema for types with user-defined converters. + return CompleteSchema(ref state, JsonSchema.True); + } + + if (parentPolymorphicTypeInfo is null && typeInfo.PolymorphismOptions is { DerivedTypes.Count: > 0 } polyOptions) + { + // This is the base type of a polymorphic type hierarchy. The schema for this type + // will include an "anyOf" property with the schemas for all derived types. + + string typeDiscriminatorKey = polyOptions.TypeDiscriminatorPropertyName; + List derivedTypes = polyOptions.DerivedTypes.ToList(); + + if (!typeInfo.Type.IsAbstract && !derivedTypes.Any(derived => derived.DerivedType == typeInfo.Type)) + { + // For non-abstract base types that haven't been explicitly configured, + // add a trivial schema to the derived types since we should support it. + derivedTypes.Add(new JsonDerivedType(typeInfo.Type)); + } + + bool containsTypesWithoutDiscriminator = derivedTypes.Exists(static derivedTypes => derivedTypes.TypeDiscriminator is null); + JsonSchemaType schemaType = JsonSchemaType.Any; + List? anyOf = new(derivedTypes.Count); + + state.PushSchemaNode(JsonSchemaConstants.AnyOfPropertyName); + + foreach (JsonDerivedType derivedType in derivedTypes) + { + Debug.Assert(derivedType.TypeDiscriminator is null or int or string, "Type discriminator does not have the expected type."); + + KeyValuePair? derivedTypeDiscriminator = null; + if (derivedType.TypeDiscriminator is { } discriminatorValue) + { + JsonNode discriminatorNode = discriminatorValue switch + { + string stringId => (JsonNode)stringId, + _ => (JsonNode)(int)discriminatorValue, + }; + + JsonSchema discriminatorSchema = new() { Constant = discriminatorNode }; + derivedTypeDiscriminator = new(typeDiscriminatorKey, discriminatorSchema); + } + + JsonTypeInfo derivedTypeInfo = typeInfo.Options.GetTypeInfo(derivedType.DerivedType); + + state.PushSchemaNode(anyOf.Count.ToString(CultureInfo.InvariantCulture)); + JsonSchema derivedSchema = MapJsonSchemaCore( + ref state, + derivedTypeInfo, + parentPolymorphicTypeInfo: typeInfo, + typeDiscriminator: derivedTypeDiscriminator, + parentPolymorphicTypeContainsTypesWithoutDiscriminator: containsTypesWithoutDiscriminator, + parentPolymorphicTypeIsNonNullable: isNonNullableType, + cacheResult: false); + + state.PopSchemaNode(); + + // Determine if all derived schemas have the same type. + if (anyOf.Count == 0) + { + schemaType = derivedSchema.Type; + } + else if (schemaType != derivedSchema.Type) + { + schemaType = JsonSchemaType.Any; + } + + anyOf.Add(derivedSchema); + } + + state.PopSchemaNode(); + + if (schemaType is not JsonSchemaType.Any) + { + // If all derived types have the same schema type, we can simplify the schema + // by moving the type keyword to the base schema and removing it from the derived schemas. + foreach (JsonSchema derivedSchema in anyOf) + { + derivedSchema.Type = JsonSchemaType.Any; + + if (derivedSchema.KeywordCount == 0) + { + // if removing the type results in an empty schema, + // remove the anyOf array entirely since it's always true. + anyOf = null; + break; + } + } + } + + schema = new() + { + Type = schemaType, + AnyOf = anyOf, + + // If all derived types have a discriminator, we can require it in the base schema. + Required = containsTypesWithoutDiscriminator ? null : new() { typeDiscriminatorKey }, + }; + + return CompleteSchema(ref state, schema); + } + + if (Nullable.GetUnderlyingType(typeInfo.Type) is Type nullableElementType) + { + JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(nullableElementType); + customConverter = ExtractCustomNullableConverter(customConverter); + schema = MapJsonSchemaCore(ref state, elementTypeInfo, customConverter: customConverter, cacheResult: false); + + if (schema.Enum != null) + { + Debug.Assert(elementTypeInfo.Type.IsEnum, "The enum keyword should only be populated by schemas for enum types."); + schema.Enum.Add(null); // Append null to the enum array. + } + + return CompleteSchema(ref state, schema); + } + + switch (typeInfo.Kind) + { + case JsonTypeInfoKind.Object: + List>? properties = null; + List? required = null; + JsonSchema? additionalProperties = null; + + JsonUnmappedMemberHandling effectiveUnmappedMemberHandling = typeInfo.UnmappedMemberHandling ?? typeInfo.Options.UnmappedMemberHandling; + if (effectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) + { + // Disallow unspecified properties. + additionalProperties = JsonSchema.False; + } + + if (typeDiscriminator is { } typeDiscriminatorPair) + { + (properties = new()).Add(typeDiscriminatorPair); + if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) + { + // Require the discriminator here since it's not common to all derived types. + (required = new()).Add(typeDiscriminatorPair.Key); + } + } + + Func? parameterInfoMapper = ResolveJsonConstructorParameterMapper(typeInfo); + + state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + if (property is { Get: null, Set: null } or { IsExtensionData: true }) + { + continue; // Skip JsonIgnored properties and extension data + } + + JsonNumberHandling? propertyNumberHandling = property.NumberHandling ?? effectiveNumberHandling; + JsonTypeInfo propertyTypeInfo = typeInfo.Options.GetTypeInfo(property.PropertyType); + + // Resolve the attribute provider for the property. + ICustomAttributeProvider? attributeProvider = ResolveAttributeProvider(typeInfo.Type, property); + + // Declare the property as nullable if either getter or setter are nullable. + bool isNonNullableProperty = false; + if (attributeProvider is MemberInfo memberInfo) + { + NullabilityInfo nullabilityInfo = state.NullabilityInfoContext.GetMemberNullability(memberInfo); + isNonNullableProperty = + (property.Get is null || nullabilityInfo.ReadState is NullabilityState.NotNull) && + (property.Set is null || nullabilityInfo.WriteState is NullabilityState.NotNull); + } + + bool isRequired = property.IsRequired; + bool hasDefaultValue = false; + JsonNode? defaultValue = null; + + ParameterInfo? associatedParameter = parameterInfoMapper?.Invoke(property); + if (associatedParameter != null) + { + ResolveParameterInfo( + associatedParameter, + propertyTypeInfo, + state.NullabilityInfoContext, + out hasDefaultValue, + out defaultValue, + out bool isNonNullableParameter, + ref isRequired); + + isNonNullableProperty &= isNonNullableParameter; + } + + state.PushSchemaNode(property.Name); + JsonSchema propertySchema = MapJsonSchemaCore( + ref state, + propertyTypeInfo, + parentType: typeInfo.Type, + propertyInfo: property, + parameterInfo: associatedParameter, + propertyAttributeProvider: attributeProvider, + isNonNullableType: isNonNullableProperty, + customConverter: property.CustomConverter, + customNumberHandling: propertyNumberHandling); + + state.PopSchemaNode(); + + if (hasDefaultValue) + { + JsonSchema.EnsureMutable(ref propertySchema); + propertySchema.DefaultValue = defaultValue; + propertySchema.HasDefaultValue = true; + } + + (properties ??= new()).Add(new(property.Name, propertySchema)); + + if (isRequired) + { + (required ??= new()).Add(property.Name); + } + } + + state.PopSchemaNode(); + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Object, + Properties = properties, + Required = required, + AdditionalProperties = additionalProperties, + }); + + case JsonTypeInfoKind.Enumerable: + Type elementType = GetElementType(typeInfo); + JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementType); + + if (typeDiscriminator is null) + { + state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); + JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); + state.PopSchemaNode(); + + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Array, + Items = items.IsTrue ? null : items, + }); + } + else + { + // Polymorphic enumerable types are represented using a wrapping object: + // { "$type" : "discriminator", "$values" : [element1, element2, ...] } + // Which corresponds to the schema + // { "properties" : { "$type" : { "const" : "discriminator" }, "$values" : { "type" : "array", "items" : { ... } } } } + const string ValuesKeyword = "$values"; + + state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); + state.PushSchemaNode(ValuesKeyword); + state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); + + JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); + + state.PopSchemaNode(); + state.PopSchemaNode(); + state.PopSchemaNode(); + + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Object, + Properties = new() + { + typeDiscriminator.Value, + new(ValuesKeyword, + new JsonSchema + { + Type = JsonSchemaType.Array, + Items = items.IsTrue ? null : items, + }), + }, + Required = parentPolymorphicTypeContainsTypesWithoutDiscriminator ? new() { typeDiscriminator.Value.Key } : null, + }); + } + + case JsonTypeInfoKind.Dictionary: + Type valueType = GetElementType(typeInfo); + JsonTypeInfo valueTypeInfo = typeInfo.Options.GetTypeInfo(valueType); + + List>? dictProps = null; + List? dictRequired = null; + + if (typeDiscriminator is { } dictDiscriminator) + { + dictProps = new() { dictDiscriminator }; + if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) + { + // Require the discriminator here since it's not common to all derived types. + dictRequired = new() { dictDiscriminator.Key }; + } + } + + state.PushSchemaNode(JsonSchemaConstants.AdditionalPropertiesPropertyName); + JsonSchema valueSchema = MapJsonSchemaCore(ref state, valueTypeInfo, customNumberHandling: effectiveNumberHandling); + state.PopSchemaNode(); + + return CompleteSchema(ref state, new() + { + Type = JsonSchemaType.Object, + Properties = dictProps, + Required = dictRequired, + AdditionalProperties = valueSchema.IsTrue ? null : valueSchema, + }); + + default: + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.None, "The default case should handle unrecognize type kinds."); + + if (_simpleTypeSchemaFactories.TryGetValue(typeInfo.Type, out Func? simpleTypeSchemaFactory)) + { + schema = simpleTypeSchemaFactory(effectiveNumberHandling); + } + else if (typeInfo.Type.IsEnum) + { + schema = GetEnumConverterSchema(typeInfo, effectiveConverter); + } + else + { + schema = JsonSchema.True; + } + + return CompleteSchema(ref state, schema); + } + + JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) + { + if (schema.Ref is null) + { + // 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. + bool isNullableSchema = (propertyInfo != null || parameterInfo != null) + ? !isNonNullableType + : CanBeNull(typeInfo.Type) && !parentPolymorphicTypeIsNonNullable && !state.ExporterOptions.TreatNullObliviousAsNonNullable; + + if (isNullableSchema) + { + schema.MakeNullable(); + } + } + + if (state.ExporterOptions.TransformSchemaNode != null) + { + // Prime the schema for invocation by the JsonNode transformer. + schema.GenerationContext = exporterContext; + } + + return schema; + } + } + + private readonly ref struct GenerationState + { + private const int DefaultMaxDepth = 64; + private readonly List _currentPath = new(); + private readonly Dictionary<(JsonTypeInfo, JsonPropertyInfo?), string[]> _generated = new(); + private readonly int _maxDepth; + + public GenerationState(JsonSchemaExporterOptions exporterOptions, JsonSerializerOptions options, NullabilityInfoContext? nullabilityInfoContext = null) + { + ExporterOptions = exporterOptions; + NullabilityInfoContext = nullabilityInfoContext ?? new(); + _maxDepth = options.MaxDepth is 0 ? DefaultMaxDepth : options.MaxDepth; + } + + public JsonSchemaExporterOptions ExporterOptions { get; } + public NullabilityInfoContext NullabilityInfoContext { get; } + public int CurrentDepth => _currentPath.Count; + + public void PushSchemaNode(string nodeId) + { + if (CurrentDepth == _maxDepth) + { + ThrowHelpers.ThrowInvalidOperationException_MaxDepthReached(); + } + + _currentPath.Add(nodeId); + } + + public void PopSchemaNode() + { + _currentPath.RemoveAt(_currentPath.Count - 1); + } + + /// + /// Registers the current schema node generation context; if it has already been generated return a JSON pointer to its location. + /// + public bool TryGetExistingJsonPointer(in JsonSchemaExporterContext context, [NotNullWhen(true)] out string? existingJsonPointer) + { + (JsonTypeInfo, JsonPropertyInfo?) key = (context.TypeInfo, context.PropertyInfo); +#if NET + ref string[]? pathToSchema = ref CollectionsMarshal.GetValueRefOrAddDefault(_generated, key, out bool exists); +#else + bool exists = _generated.TryGetValue(key, out string[]? pathToSchema); +#endif + if (exists) + { + existingJsonPointer = FormatJsonPointer(pathToSchema); + return true; + } +#if NET + pathToSchema = context._path; +#else + _generated[key] = context._path; +#endif + existingJsonPointer = null; + return false; + } + + public JsonSchemaExporterContext CreateContext( + JsonTypeInfo typeInfo, + JsonTypeInfo? baseTypeInfo, + Type? declaringType, + JsonPropertyInfo? propertyInfo, + ParameterInfo? parameterInfo, + ICustomAttributeProvider? propertyAttributeProvider) + { + return new JsonSchemaExporterContext(typeInfo, baseTypeInfo, declaringType, propertyInfo, parameterInfo, propertyAttributeProvider, _currentPath.ToArray()); + } + + private static string FormatJsonPointer(ReadOnlySpan path) + { + if (path.IsEmpty) + { + return "#"; + } + + StringBuilder sb = new(); + _ = sb.Append('#'); + + for (int i = 0; i < path.Length; i++) + { + string segment = path[i]; + if (segment.AsSpan().IndexOfAny('~', '/') != -1) + { +#pragma warning disable CA1307 // Specify StringComparison for clarity + segment = segment.Replace("~", "~0").Replace("/", "~1"); +#pragma warning restore CA1307 + } + + _ = sb.Append('/'); + _ = sb.Append(segment); + } + + return sb.ToString(); + } + } + + private static readonly Dictionary> _simpleTypeSchemaFactories = new() + { + [typeof(object)] = _ => JsonSchema.True, + [typeof(bool)] = _ => new JsonSchema { Type = JsonSchemaType.Boolean }, + [typeof(byte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(ushort)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(uint)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(ulong)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(sbyte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(short)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(int)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(long)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(float)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), + [typeof(double)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), + [typeof(decimal)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling), +#if NET6_0_OR_GREATER + [typeof(Half)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), +#endif +#if NET7_0_OR_GREATER + [typeof(UInt128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), + [typeof(Int128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), +#endif + [typeof(char)] = _ => new JsonSchema { Type = JsonSchemaType.String, MinLength = 1, MaxLength = 1 }, + [typeof(string)] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(byte[])] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(Memory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(ReadOnlyMemory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, + [typeof(DateTime)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, + [typeof(DateTimeOffset)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, + [typeof(TimeSpan)] = _ => new JsonSchema + { + Comment = "Represents a System.TimeSpan value.", + Type = JsonSchemaType.String, + Pattern = @"^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$", + }, + +#if NET6_0_OR_GREATER + [typeof(DateOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date" }, + [typeof(TimeOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "time" }, +#endif + [typeof(Guid)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uuid" }, + [typeof(Uri)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uri" }, + [typeof(Version)] = _ => new JsonSchema + { + Comment = "Represents a version string.", + Type = JsonSchemaType.String, + Pattern = @"^\d+(\.\d+){1,3}$", + }, + + [typeof(JsonDocument)] = _ => JsonSchema.True, + [typeof(JsonElement)] = _ => JsonSchema.True, + [typeof(JsonNode)] = _ => JsonSchema.True, + [typeof(JsonValue)] = _ => JsonSchema.True, + [typeof(JsonObject)] = _ => new JsonSchema { Type = JsonSchemaType.Object }, + [typeof(JsonArray)] = _ => new JsonSchema { Type = JsonSchemaType.Array }, + }; + + // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/JsonPrimitiveConverter.cs#L36-L69 + private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, JsonNumberHandling numberHandling, bool isIeeeFloatingPoint = false) + { + Debug.Assert(schemaType is JsonSchemaType.Integer or JsonSchemaType.Number, "schema type must be number or integer"); + Debug.Assert(!isIeeeFloatingPoint || schemaType is JsonSchemaType.Number, "If specifying IEEE the schema type must be number"); + + string? pattern = null; + + if ((numberHandling & (JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)) != 0) + { + pattern = schemaType is JsonSchemaType.Integer + ? @"^-?(?:0|[1-9]\d*)$" + : isIeeeFloatingPoint + ? @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$" + : @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; + + schemaType |= JsonSchemaType.String; + } + + if (isIeeeFloatingPoint && (numberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0) + { + return new JsonSchema + { + AnyOf = new() + { + new JsonSchema { Type = schemaType, Pattern = pattern }, + new JsonSchema { Enum = new() { (JsonNode)"NaN", (JsonNode)"Infinity", (JsonNode)"-Infinity" } }, + }, + }; + } + + return new JsonSchema { Type = schemaType, Pattern = pattern }; + } + + // Uses reflection to determine the element type of an enumerable or dictionary type + // Workaround for https://github.com/dotnet/runtime/issues/77306#issuecomment-2007887560 + private static Type GetElementType(JsonTypeInfo typeInfo) + { + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary, "TypeInfo must be of collection type"); + _elementTypeProperty ??= typeof(JsonTypeInfo).GetProperty("ElementType", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return (Type)_elementTypeProperty?.GetValue(typeInfo)!; + } + + private static PropertyInfo? _elementTypeProperty; + + // The .NET 8 source generator doesn't populate attribute providers for properties + // cf. https://github.com/dotnet/runtime/issues/100095 + // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property + // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static ICustomAttributeProvider? ResolveAttributeProvider(Type? declaringType, JsonPropertyInfo? propertyInfo) + { + if (declaringType is null || propertyInfo is null) + { + return null; + } + + if (propertyInfo.AttributeProvider is { } provider) + { + return provider; + } + + _memberNameProperty ??= typeof(JsonPropertyInfo).GetProperty("MemberName", BindingFlags.Instance | BindingFlags.NonPublic)!; + var memberName = (string?)_memberNameProperty.GetValue(propertyInfo); + if (memberName is not null) + { + return declaringType.GetMember(memberName, MemberTypes.Property | MemberTypes.Field, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).FirstOrDefault(); + } + + return null; + } + + private static PropertyInfo? _memberNameProperty; + + // Uses reflection to determine any custom converters specified for the element of a nullable type. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static JsonConverter? ExtractCustomNullableConverter(JsonConverter? converter) + { + Debug.Assert(converter is null || IsBuiltInConverter(converter), "If specified the converter must be built-in."); + + // There is unfortunately no way in which we can obtain the element converter from a nullable converter without resorting to private reflection + // https://github.com/dotnet/runtime/blob/release/8.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/NullableConverter.cs#L15-L17 + Type? converterType = converter?.GetType(); + if (converterType?.Name == "NullableConverter`1") + { + FieldInfo elementConverterField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_elementConverter"); + return (JsonConverter)elementConverterField!.GetValue(converter)!; + } + + return null; + } + + private static void ValidateOptions(JsonSerializerOptions options) + { + if (options.ReferenceHandler == ReferenceHandler.Preserve) + { + ThrowHelpers.ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported(); + } + + options.MakeReadOnly(); + } + + private static void ResolveParameterInfo( + ParameterInfo parameter, + JsonTypeInfo parameterTypeInfo, + NullabilityInfoContext nullabilityInfoContext, + out bool hasDefaultValue, + out JsonNode? defaultValue, + out bool isNonNullable, + ref bool isRequired) + { + Debug.Assert(parameterTypeInfo.Type == parameter.ParameterType, "The typeInfo type must match the ParameterInfo type."); + + // Incorporate the nullability information from the parameter. + isNonNullable = nullabilityInfoContext.GetParameterNullability(parameter) is NullabilityState.NotNull; + + if (parameter.HasDefaultValue) + { + // Append the default value to the description. + object? defaultVal = parameter.GetNormalizedDefaultValue(); + defaultValue = JsonSerializer.SerializeToNode(defaultVal, parameterTypeInfo); + hasDefaultValue = true; + } + else + { + // Parameter is not optional, mark as required. + isRequired = true; + defaultValue = null; + hasDefaultValue = false; + } + } + + // Uses reflection to determine schema for enum types + // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs#L498-L521 + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConverter converter) + { + Debug.Assert(typeInfo.Type.IsEnum && IsBuiltInConverter(converter), "must be using a built-in enum converter."); + + if (converter is JsonConverterFactory factory) + { + converter = factory.CreateConverter(typeInfo.Type, typeInfo.Options)!; + } + + Type converterType = converter.GetType(); + FieldInfo converterOptionsField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_converterOptions"); + FieldInfo namingPolicyField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_namingPolicy"); + + const int EnumConverterOptionsAllowStrings = 1; + var converterOptions = (int)converterOptionsField!.GetValue(converter)!; + if ((converterOptions & EnumConverterOptionsAllowStrings) != 0) + { + // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings + // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity. + + if (typeInfo.Type.GetCustomAttribute() is not null) + { + // Do not report enum values in case of flags. + return new() { Type = JsonSchemaType.String }; + } + + var namingPolicy = (JsonNamingPolicy?)namingPolicyField!.GetValue(converter)!; + JsonArray enumValues = new(); + foreach (string name in Enum.GetNames(typeInfo.Type)) + { + // This does not account for custom names specified via the new + // JsonStringEnumMemberNameAttribute introduced in .NET 9. + string effectiveName = namingPolicy?.ConvertName(name) ?? name; + enumValues.Add((JsonNode)effectiveName); + } + + return new() { Enum = enumValues }; + } + + return new() { Type = JsonSchemaType.Integer }; + } + + private static NullabilityState GetParameterNullability(this NullabilityInfoContext context, ParameterInfo parameterInfo) + { +#if !NET9_0_OR_GREATER + // Workaround for https://github.com/dotnet/runtime/issues/92487 + if (GetGenericParameterDefinition(parameterInfo) is { ParameterType: { IsGenericParameter: true } typeParam }) + { + // Step 1. Look for nullable annotations on the type parameter. + if (GetNullableFlags(typeParam) is byte[] flags) + { + return TranslateByte(flags[0]); + } + + // Step 2. Look for nullable annotations on the generic method declaration. + if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) + { + return TranslateByte(flag); + } + + // Step 3. Look for nullable annotations on the generic method declaration. + if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) + { + return TranslateByte(flag2); + } + + // Default to nullable. + return NullabilityState.Nullable; + +#if NETCOREAPP + [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", + Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] +#endif + static byte[]? GetNullableFlags(MemberInfo member) + { + Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => + { + Type attrType = attr.GetType(); + return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableAttribute"; + }); + + return (byte[])attr?.GetType().GetField("NullableFlags")?.GetValue(attr)!; + } + + [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", + Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] + static byte? GetNullableContextFlag(MemberInfo member) + { + Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => + { + Type attrType = attr.GetType(); + return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableContextAttribute"; + }); + + return (byte?)attr?.GetType().GetField("Flag")?.GetValue(attr)!; + } + +#pragma warning disable S109 // Magic numbers should not be used + static NullabilityState TranslateByte(byte b) => b switch + { + 1 => NullabilityState.NotNull, + 2 => NullabilityState.Nullable, + _ => NullabilityState.Unknown + }; +#pragma warning restore S109 // Magic numbers should not be used + } + + static ParameterInfo GetGenericParameterDefinition(ParameterInfo parameter) + { + if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } + or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) + { + var genericMethod = (MethodBase)GetGenericMemberDefinition(parameter.Member); + return genericMethod.GetParameters()[parameter.Position]; + } + + return parameter; + } + + [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", + Justification = "Looking up the generic member definition of the provided member.")] + static MemberInfo GetGenericMemberDefinition(MemberInfo member) + { + if (member is Type type) + { + return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; + } + + if (member.DeclaringType!.IsConstructedGenericType) + { + const BindingFlags AllMemberFlags = + BindingFlags.Static | BindingFlags.Instance | + BindingFlags.Public | BindingFlags.NonPublic; + + return member.DeclaringType.GetGenericTypeDefinition() + .GetMember(member.Name, AllMemberFlags) + .First(m => m.MetadataToken == member.MetadataToken); + } + + if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) + { + return method.GetGenericMethodDefinition(); + } + + return member; + } +#endif + return context.Create(parameterInfo).WriteState; + } + + // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 + private static object? GetNormalizedDefaultValue(this ParameterInfo parameterInfo) + { + Type parameterType = parameterInfo.ParameterType; + object? defaultValue = parameterInfo.DefaultValue; + + if (defaultValue is null) + { + return null; + } + + // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. + if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) + { + return null; + } + + // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly + // cf. https://github.com/dotnet/runtime/issues/68647 + if (parameterType.IsEnum) + { + return Enum.ToObject(parameterType, defaultValue); + } + + if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) + { + return Enum.ToObject(underlyingType, defaultValue); + } + + return defaultValue; + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static FieldInfo GetPrivateFieldWithPotentiallyTrimmedMetadata(this Type type, string fieldName) + { + FieldInfo? field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + if (field is null) + { + throw new InvalidOperationException( + $"Could not resolve metadata for field '{fieldName}' in type '{type}'. " + + "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."); + } + + return field; + } + + // Resolves the parameters of the deserialization constructor for a type, if they exist. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static Func? ResolveJsonConstructorParameterMapper(JsonTypeInfo typeInfo) + { + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object, "Should only be passed object JSON kinds."); + + if (typeInfo.Properties.Count > 0 && + typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used + typeInfo.Type.TryGetDeserializationConstructor(useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) + { + ParameterInfo[]? parameters = ctor?.GetParameters(); + if (parameters?.Length > 0) + { + Dictionary dict = new(parameters.Length); + foreach (ParameterInfo parameter in parameters) + { + if (parameter.Name is not null) + { + // We don't care about null parameter names or conflicts since they + // would have already been rejected by JsonTypeInfo exporterOptions. + dict[new(parameter.Name, parameter.ParameterType)] = parameter; + } + } + + return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; + } + } + + return null; + } + + // Parameter to property matching semantics as declared in + // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 + private readonly struct ParameterLookupKey : IEquatable + { + public ParameterLookupKey(string name, Type type) + { + Name = name; + Type = type; + } + + public string Name { get; } + public Type Type { get; } + + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); + public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); + public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); + } + + // Resolves the deserialization constructor for a type using logic copied from + // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + private static bool TryGetDeserializationConstructor( + this Type type, + bool useDefaultCtorInAnnotatedStructs, + out ConstructorInfo? deserializationCtor) + { + ConstructorInfo? ctorWithAttribute = null; + ConstructorInfo? publicParameterlessCtor = null; + ConstructorInfo? lonePublicCtor = null; + + ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + if (constructors.Length == 1) + { + lonePublicCtor = constructors[0]; + } + + foreach (ConstructorInfo constructor in constructors) + { + if (HasJsonConstructorAttribute(constructor)) + { + if (ctorWithAttribute != null) + { + deserializationCtor = null; + return false; + } + + ctorWithAttribute = constructor; + } + else if (constructor.GetParameters().Length == 0) + { + publicParameterlessCtor = constructor; + } + } + + // Search for non-public ctors with [JsonConstructor]. + foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (HasJsonConstructorAttribute(constructor)) + { + if (ctorWithAttribute != null) + { + deserializationCtor = null; + return false; + } + + ctorWithAttribute = constructor; + } + } + + // Structs will use default constructor if attribute isn't used. + if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) + { + deserializationCtor = null; + return true; + } + + deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; + return true; + + static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => + constructorInfo.GetCustomAttribute() != null; + } + + private static bool IsBuiltInConverter(JsonConverter converter) => + converter.GetType().Assembly == typeof(JsonConverter).Assembly; + + // Resolves the nullable reference type annotations for a property or field, + // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. + private static NullabilityInfo GetMemberNullability(this NullabilityInfoContext context, MemberInfo memberInfo) + { + Debug.Assert(memberInfo is PropertyInfo or FieldInfo, "Member must be property or field."); + return memberInfo is PropertyInfo prop + ? context.Create(prop) + : context.Create((FieldInfo)memberInfo); + } + + private static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; + + private static class JsonSchemaConstants + { + public const string SchemaPropertyName = "$schema"; + public const string RefPropertyName = "$ref"; + public const string CommentPropertyName = "$comment"; + public const string TitlePropertyName = "title"; + public const string DescriptionPropertyName = "description"; + public const string TypePropertyName = "type"; + public const string FormatPropertyName = "format"; + public const string PatternPropertyName = "pattern"; + public const string PropertiesPropertyName = "properties"; + public const string RequiredPropertyName = "required"; + public const string ItemsPropertyName = "items"; + public const string AdditionalPropertiesPropertyName = "additionalProperties"; + public const string EnumPropertyName = "enum"; + public const string NotPropertyName = "not"; + public const string AnyOfPropertyName = "anyOf"; + public const string ConstPropertyName = "const"; + public const string DefaultPropertyName = "default"; + public const string MinLengthPropertyName = "minLength"; + public const string MaxLengthPropertyName = "maxLength"; + } + + private static class ThrowHelpers + { + [DoesNotReturn] + public static void ThrowInvalidOperationException_MaxDepthReached() => + throw new InvalidOperationException("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting."); + + [DoesNotReturn] + public static void ThrowInvalidOperationException_TrimmedMethodParameters(MethodBase method) => + throw new InvalidOperationException($"The parameters for method '{method}' have been trimmed away."); + + [DoesNotReturn] + public static void ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported() => + throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs new file mode 100644 index 00000000000..3602ee46df4 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.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. + +#if !NET9_0_OR_GREATER +using System; +using System.Reflection; +using System.Text.Json.Serialization.Metadata; + +namespace System.Text.Json.Schema; + +/// +/// Defines the context in which a JSON schema within a type graph is being generated. +/// +#if !SHARED_PROJECT +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif +internal readonly struct JsonSchemaExporterContext +{ +#pragma warning disable IDE1006 // Naming Styles + internal readonly string[] _path; +#pragma warning restore IDE1006 // Naming Styles + + internal JsonSchemaExporterContext( + JsonTypeInfo typeInfo, + JsonTypeInfo? baseTypeInfo, + Type? declaringType, + JsonPropertyInfo? propertyInfo, + ParameterInfo? parameterInfo, + ICustomAttributeProvider? propertyAttributeProvider, + string[] path) + { + TypeInfo = typeInfo; + DeclaringType = declaringType; + BaseTypeInfo = baseTypeInfo; + PropertyInfo = propertyInfo; + ParameterInfo = parameterInfo; + PropertyAttributeProvider = propertyAttributeProvider; + _path = path; + } + + /// + /// Gets the path to the schema document currently being generated. + /// + public ReadOnlySpan Path => _path; + + /// + /// Gets the for the type being processed. + /// + public JsonTypeInfo TypeInfo { get; } + + /// + /// Gets the declaring type of the property or parameter being processed. + /// + public Type? DeclaringType { get; } + + /// + /// Gets the type info for the polymorphic base type if generated as a derived type. + /// + public JsonTypeInfo? BaseTypeInfo { get; } + + /// + /// Gets the if the schema is being generated for a property. + /// + public JsonPropertyInfo? PropertyInfo { get; } + + /// + /// Gets the if a constructor parameter + /// has been associated with the accompanying . + /// + public ParameterInfo? ParameterInfo { get; } + + /// + /// Gets the corresponding to the property or field being processed. + /// + public ICustomAttributeProvider? PropertyAttributeProvider { get; } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs new file mode 100644 index 00000000000..53a269ea612 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET9_0_OR_GREATER +using System; +using System.Text.Json.Nodes; + +namespace System.Text.Json.Schema; + +/// +/// Controls the behavior of the class. +/// +#if !SHARED_PROJECT +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +#endif +internal sealed class JsonSchemaExporterOptions +{ + /// + /// Gets the default configuration object used by . + /// + public static JsonSchemaExporterOptions Default { get; } = new(); + + /// + /// Gets a value indicating whether non-nullable schemas should be generated for null oblivious reference types. + /// + /// + /// Defaults to . Due to restrictions in the run-time representation of nullable reference types + /// most occurrences are null oblivious and are treated as nullable by the serializer. A notable exception to that rule + /// are nullability annotations of field, property and constructor parameters which are represented in the contract metadata. + /// + public bool TreatNullObliviousAsNonNullable { get; init; } + + /// + /// Gets a callback that is invoked for every schema that is generated within the type graph. + /// + public Func? TransformSchemaNode { get; init; } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs new file mode 100644 index 00000000000..bd9b132cd0f --- /dev/null +++ b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable SA1623 // Property summary documentation should match accessors + +namespace System.Reflection +{ + /// + /// A class that represents nullability info. + /// + [ExcludeFromCodeCoverage] + internal sealed class NullabilityInfo + { + internal NullabilityInfo(Type type, NullabilityState readState, NullabilityState writeState, + NullabilityInfo? elementType, NullabilityInfo[] typeArguments) + { + Type = type; + ReadState = readState; + WriteState = writeState; + ElementType = elementType; + GenericTypeArguments = typeArguments; + } + + /// + /// The of the member or generic parameter + /// to which this NullabilityInfo belongs. + /// + public Type Type { get; } + + /// + /// The nullability read state of the member. + /// + public NullabilityState ReadState { get; internal set; } + + /// + /// The nullability write state of the member. + /// + public NullabilityState WriteState { get; internal set; } + + /// + /// If the member type is an array, gives the of the elements of the array, null otherwise. + /// + public NullabilityInfo? ElementType { get; } + + /// + /// If the member type is a generic type, gives the array of for each type parameter. + /// + public NullabilityInfo[] GenericTypeArguments { get; } + } + + /// + /// An enum that represents nullability state. + /// + internal enum NullabilityState + { + /// + /// Nullability context not enabled (oblivious). + /// + Unknown, + + /// + /// Non nullable value or reference type. + /// + NotNull, + + /// + /// Nullable value or reference type. + /// + Nullable, + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs new file mode 100644 index 00000000000..3edee1b9cb8 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs @@ -0,0 +1,661 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET6_0_OR_GREATER +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable S109 // Magic numbers should not be used +#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable S4136 // Method overloads should be grouped together +#pragma warning disable SA1202 // Elements should be ordered by access +#pragma warning disable IDE1006 // Naming Styles + +namespace System.Reflection +{ + /// + /// Provides APIs for populating nullability information/context from reflection members: + /// , , and . + /// + [ExcludeFromCodeCoverage] + internal sealed class NullabilityInfoContext + { + private const string CompilerServicesNameSpace = "System.Runtime.CompilerServices"; + private readonly Dictionary _publicOnlyModules = new(); + private readonly Dictionary _context = new(); + + [Flags] + private enum NotAnnotatedStatus + { + None = 0x0, // no restriction, all members annotated + Private = 0x1, // private members not annotated + Internal = 0x2, // internal members not annotated + } + + private NullabilityState? GetNullableContext(MemberInfo? memberInfo) + { + while (memberInfo != null) + { + if (_context.TryGetValue(memberInfo, out NullabilityState state)) + { + return state; + } + + foreach (CustomAttributeData attribute in memberInfo.GetCustomAttributesData()) + { + if (attribute.AttributeType.Name == "NullableContextAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNameSpace && + attribute.ConstructorArguments.Count == 1) + { + state = TranslateByte(attribute.ConstructorArguments[0].Value); + _context.Add(memberInfo, state); + return state; + } + } + + memberInfo = memberInfo.DeclaringType; + } + + return null; + } + + /// + /// Populates for the given . + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the parameterInfo parameter is null. + /// . + public NullabilityInfo Create(ParameterInfo parameterInfo) + { + IList attributes = parameterInfo.GetCustomAttributesData(); + NullableAttributeStateParser parser = parameterInfo.Member is MethodBase method && IsPrivateOrInternalMethodAndAnnotationDisabled(method) + ? NullableAttributeStateParser.Unknown + : CreateParser(attributes); + NullabilityInfo nullability = GetNullabilityInfo(parameterInfo.Member, parameterInfo.ParameterType, parser); + + if (nullability.ReadState != NullabilityState.Unknown) + { + CheckParameterMetadataType(parameterInfo, nullability); + } + + CheckNullabilityAttributes(nullability, attributes); + return nullability; + } + + private void CheckParameterMetadataType(ParameterInfo parameter, NullabilityInfo nullability) + { + ParameterInfo? metaParameter; + MemberInfo metaMember; + + switch (parameter.Member) + { + case ConstructorInfo ctor: + var metaCtor = (ConstructorInfo)GetMemberMetadataDefinition(ctor); + metaMember = metaCtor; + metaParameter = GetMetaParameter(metaCtor, parameter); + break; + + case MethodInfo method: + MethodInfo metaMethod = GetMethodMetadataDefinition(method); + metaMember = metaMethod; + metaParameter = string.IsNullOrEmpty(parameter.Name) ? metaMethod.ReturnParameter : GetMetaParameter(metaMethod, parameter); + break; + + default: + return; + } + + if (metaParameter != null) + { + CheckGenericParameters(nullability, metaMember, metaParameter.ParameterType, parameter.Member.ReflectedType); + } + } + + private static ParameterInfo? GetMetaParameter(MethodBase metaMethod, ParameterInfo parameter) + { + var parameters = metaMethod.GetParameters(); + for (int i = 0; i < parameters.Length; i++) + { + if (parameter.Position == i && + parameter.Name == parameters[i].Name) + { + return parameters[i]; + } + } + + return null; + } + + private static MethodInfo GetMethodMetadataDefinition(MethodInfo method) + { + if (method.IsGenericMethod && !method.IsGenericMethodDefinition) + { + method = method.GetGenericMethodDefinition(); + } + + return (MethodInfo)GetMemberMetadataDefinition(method); + } + + private static void CheckNullabilityAttributes(NullabilityInfo nullability, IList attributes) + { + var codeAnalysisReadState = NullabilityState.Unknown; + var codeAnalysisWriteState = NullabilityState.Unknown; + + foreach (CustomAttributeData attribute in attributes) + { + if (attribute.AttributeType.Namespace == "System.Diagnostics.CodeAnalysis") + { + if (attribute.AttributeType.Name == "NotNullAttribute") + { + codeAnalysisReadState = NullabilityState.NotNull; + } + else if ((attribute.AttributeType.Name == "MaybeNullAttribute" || + attribute.AttributeType.Name == "MaybeNullWhenAttribute") && + codeAnalysisReadState == NullabilityState.Unknown && + !IsValueTypeOrValueTypeByRef(nullability.Type)) + { + codeAnalysisReadState = NullabilityState.Nullable; + } + else if (attribute.AttributeType.Name == "DisallowNullAttribute") + { + codeAnalysisWriteState = NullabilityState.NotNull; + } + else if (attribute.AttributeType.Name == "AllowNullAttribute" && + codeAnalysisWriteState == NullabilityState.Unknown && + !IsValueTypeOrValueTypeByRef(nullability.Type)) + { + codeAnalysisWriteState = NullabilityState.Nullable; + } + } + } + + if (codeAnalysisReadState != NullabilityState.Unknown) + { + nullability.ReadState = codeAnalysisReadState; + } + + if (codeAnalysisWriteState != NullabilityState.Unknown) + { + nullability.WriteState = codeAnalysisWriteState; + } + } + + /// + /// Populates for the given . + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the propertyInfo parameter is null. + /// . + public NullabilityInfo Create(PropertyInfo propertyInfo) + { + MethodInfo? getter = propertyInfo.GetGetMethod(true); + MethodInfo? setter = propertyInfo.GetSetMethod(true); + bool annotationsDisabled = (getter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(getter)) + && (setter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(setter)); + NullableAttributeStateParser parser = annotationsDisabled ? NullableAttributeStateParser.Unknown : CreateParser(propertyInfo.GetCustomAttributesData()); + NullabilityInfo nullability = GetNullabilityInfo(propertyInfo, propertyInfo.PropertyType, parser); + + if (getter != null) + { + CheckNullabilityAttributes(nullability, getter.ReturnParameter.GetCustomAttributesData()); + } + else + { + nullability.ReadState = NullabilityState.Unknown; + } + + if (setter != null) + { + CheckNullabilityAttributes(nullability, setter.GetParameters().Last().GetCustomAttributesData()); + } + else + { + nullability.WriteState = NullabilityState.Unknown; + } + + return nullability; + } + + private bool IsPrivateOrInternalMethodAndAnnotationDisabled(MethodBase method) + { + if ((method.IsPrivate || method.IsFamilyAndAssembly || method.IsAssembly) && + IsPublicOnly(method.IsPrivate, method.IsFamilyAndAssembly, method.IsAssembly, method.Module)) + { + return true; + } + + return false; + } + + /// + /// Populates for the given . + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the eventInfo parameter is null. + /// . + public NullabilityInfo Create(EventInfo eventInfo) + { + return GetNullabilityInfo(eventInfo, eventInfo.EventHandlerType!, CreateParser(eventInfo.GetCustomAttributesData())); + } + + /// + /// Populates for the given + /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's + /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. + /// + /// The parameter which nullability info gets populated. + /// If the fieldInfo parameter is null. + /// . + public NullabilityInfo Create(FieldInfo fieldInfo) + { + IList attributes = fieldInfo.GetCustomAttributesData(); + NullableAttributeStateParser parser = IsPrivateOrInternalFieldAndAnnotationDisabled(fieldInfo) ? NullableAttributeStateParser.Unknown : CreateParser(attributes); + NullabilityInfo nullability = GetNullabilityInfo(fieldInfo, fieldInfo.FieldType, parser); + CheckNullabilityAttributes(nullability, attributes); + return nullability; + } + + private bool IsPrivateOrInternalFieldAndAnnotationDisabled(FieldInfo fieldInfo) + { + if ((fieldInfo.IsPrivate || fieldInfo.IsFamilyAndAssembly || fieldInfo.IsAssembly) && + IsPublicOnly(fieldInfo.IsPrivate, fieldInfo.IsFamilyAndAssembly, fieldInfo.IsAssembly, fieldInfo.Module)) + { + return true; + } + + return false; + } + + private bool IsPublicOnly(bool isPrivate, bool isFamilyAndAssembly, bool isAssembly, Module module) + { + if (!_publicOnlyModules.TryGetValue(module, out NotAnnotatedStatus value)) + { + value = PopulateAnnotationInfo(module.GetCustomAttributesData()); + _publicOnlyModules.Add(module, value); + } + + if (value == NotAnnotatedStatus.None) + { + return false; + } + + if (((isPrivate || isFamilyAndAssembly) && value.HasFlag(NotAnnotatedStatus.Private)) || + (isAssembly && value.HasFlag(NotAnnotatedStatus.Internal))) + { + return true; + } + + return false; + } + + private static NotAnnotatedStatus PopulateAnnotationInfo(IList customAttributes) + { + foreach (CustomAttributeData attribute in customAttributes) + { + if (attribute.AttributeType.Name == "NullablePublicOnlyAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNameSpace && + attribute.ConstructorArguments.Count == 1) + { + if (attribute.ConstructorArguments[0].Value is bool boolValue && boolValue) + { + return NotAnnotatedStatus.Internal | NotAnnotatedStatus.Private; + } + else + { + return NotAnnotatedStatus.Private; + } + } + } + + return NotAnnotatedStatus.None; + } + + private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser) + { + int index = 0; + NullabilityInfo nullability = GetNullabilityInfo(memberInfo, type, parser, ref index); + + if (nullability.ReadState != NullabilityState.Unknown) + { + TryLoadGenericMetaTypeNullability(memberInfo, nullability); + } + + return nullability; + } + + private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser, ref int index) + { + NullabilityState state = NullabilityState.Unknown; + NullabilityInfo? elementState = null; + NullabilityInfo[] genericArgumentsState = Array.Empty(); + Type underlyingType = type; + + if (underlyingType.IsByRef || underlyingType.IsPointer) + { + underlyingType = underlyingType.GetElementType()!; + } + + if (underlyingType.IsValueType) + { + if (Nullable.GetUnderlyingType(underlyingType) is { } nullableUnderlyingType) + { + underlyingType = nullableUnderlyingType; + state = NullabilityState.Nullable; + } + else + { + state = NullabilityState.NotNull; + } + + if (underlyingType.IsGenericType) + { + ++index; + } + } + else + { + if (!parser.ParseNullableState(index++, ref state) + && GetNullableContext(memberInfo) is { } contextState) + { + state = contextState; + } + + if (underlyingType.IsArray) + { + elementState = GetNullabilityInfo(memberInfo, underlyingType.GetElementType()!, parser, ref index); + } + } + + if (underlyingType.IsGenericType) + { + Type[] genericArguments = underlyingType.GetGenericArguments(); + genericArgumentsState = new NullabilityInfo[genericArguments.Length]; + + for (int i = 0; i < genericArguments.Length; i++) + { + genericArgumentsState[i] = GetNullabilityInfo(memberInfo, genericArguments[i], parser, ref index); + } + } + + return new NullabilityInfo(type, state, state, elementState, genericArgumentsState); + } + + private static NullableAttributeStateParser CreateParser(IList customAttributes) + { + foreach (CustomAttributeData attribute in customAttributes) + { + if (attribute.AttributeType.Name == "NullableAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNameSpace && + attribute.ConstructorArguments.Count == 1) + { + return new NullableAttributeStateParser(attribute.ConstructorArguments[0].Value); + } + } + + return new NullableAttributeStateParser(null); + } + + private void TryLoadGenericMetaTypeNullability(MemberInfo memberInfo, NullabilityInfo nullability) + { + MemberInfo? metaMember = GetMemberMetadataDefinition(memberInfo); + Type? metaType = null; + if (metaMember is FieldInfo field) + { + metaType = field.FieldType; + } + else if (metaMember is PropertyInfo property) + { + metaType = GetPropertyMetaType(property); + } + + if (metaType != null) + { + CheckGenericParameters(nullability, metaMember!, metaType, memberInfo.ReflectedType); + } + } + + private static MemberInfo GetMemberMetadataDefinition(MemberInfo member) + { + Type? type = member.DeclaringType; + if ((type != null) && type.IsGenericType && !type.IsGenericTypeDefinition) + { + return NullabilityInfoHelpers.GetMemberWithSameMetadataDefinitionAs(type.GetGenericTypeDefinition(), member); + } + + return member; + } + + private static Type GetPropertyMetaType(PropertyInfo property) + { + if (property.GetGetMethod(true) is MethodInfo method) + { + return method.ReturnType; + } + + return property.GetSetMethod(true)!.GetParameters()[0].ParameterType; + } + + private void CheckGenericParameters(NullabilityInfo nullability, MemberInfo metaMember, Type metaType, Type? reflectedType) + { + if (metaType.IsGenericParameter) + { + if (nullability.ReadState == NullabilityState.NotNull) + { + _ = TryUpdateGenericParameterNullability(nullability, metaType, reflectedType); + } + } + else if (metaType.ContainsGenericParameters) + { + if (nullability.GenericTypeArguments.Length > 0) + { + Type[] genericArguments = metaType.GetGenericArguments(); + + for (int i = 0; i < genericArguments.Length; i++) + { + CheckGenericParameters(nullability.GenericTypeArguments[i], metaMember, genericArguments[i], reflectedType); + } + } + else if (nullability.ElementType is { } elementNullability && metaType.IsArray) + { + CheckGenericParameters(elementNullability, metaMember, metaType.GetElementType()!, reflectedType); + } + + // We could also follow this branch for metaType.IsPointer, but since pointers must be unmanaged this + // will be a no-op regardless + else if (metaType.IsByRef) + { + CheckGenericParameters(nullability, metaMember, metaType.GetElementType()!, reflectedType); + } + } + } + + private bool TryUpdateGenericParameterNullability(NullabilityInfo nullability, Type genericParameter, Type? reflectedType) + { + Debug.Assert(genericParameter.IsGenericParameter, "must be generic parameter"); + + if (reflectedType is not null + && !genericParameter.IsGenericMethodParameter() + && TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, reflectedType, reflectedType)) + { + return true; + } + + if (IsValueTypeOrValueTypeByRef(nullability.Type)) + { + return true; + } + + var state = NullabilityState.Unknown; + if (CreateParser(genericParameter.GetCustomAttributesData()).ParseNullableState(0, ref state)) + { + nullability.ReadState = state; + nullability.WriteState = state; + return true; + } + + if (GetNullableContext(genericParameter) is { } contextState) + { + nullability.ReadState = contextState; + nullability.WriteState = contextState; + return true; + } + + return false; + } + + private bool TryUpdateGenericTypeParameterNullabilityFromReflectedType(NullabilityInfo nullability, Type genericParameter, Type context, Type reflectedType) + { + Debug.Assert(genericParameter.IsGenericParameter && !genericParameter.IsGenericMethodParameter(), "must be generic parameter"); + + Type contextTypeDefinition = context.IsGenericType && !context.IsGenericTypeDefinition ? context.GetGenericTypeDefinition() : context; + if (genericParameter.DeclaringType == contextTypeDefinition) + { + return false; + } + + Type? baseType = contextTypeDefinition.BaseType; + if (baseType is null) + { + return false; + } + + if (!baseType.IsGenericType + || (baseType.IsGenericTypeDefinition ? baseType : baseType.GetGenericTypeDefinition()) != genericParameter.DeclaringType) + { + return TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, baseType, reflectedType); + } + + Type[] genericArguments = baseType.GetGenericArguments(); + Type genericArgument = genericArguments[genericParameter.GenericParameterPosition]; + if (genericArgument.IsGenericParameter) + { + return TryUpdateGenericParameterNullability(nullability, genericArgument, reflectedType); + } + + NullableAttributeStateParser parser = CreateParser(contextTypeDefinition.GetCustomAttributesData()); + int nullabilityStateIndex = 1; // start at 1 since index 0 is the type itself + for (int i = 0; i < genericParameter.GenericParameterPosition; i++) + { + nullabilityStateIndex += CountNullabilityStates(genericArguments[i]); + } + + return TryPopulateNullabilityInfo(nullability, parser, ref nullabilityStateIndex); + + static int CountNullabilityStates(Type type) + { + Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; + if (underlyingType.IsGenericType) + { + int count = 1; + foreach (Type genericArgument in underlyingType.GetGenericArguments()) + { + count += CountNullabilityStates(genericArgument); + } + + return count; + } + + if (underlyingType.HasElementType) + { + return (underlyingType.IsArray ? 1 : 0) + CountNullabilityStates(underlyingType.GetElementType()!); + } + + return type.IsValueType ? 0 : 1; + } + } + +#pragma warning disable SA1204 // Static elements should appear before instance elements + private static bool TryPopulateNullabilityInfo(NullabilityInfo nullability, NullableAttributeStateParser parser, ref int index) +#pragma warning restore SA1204 // Static elements should appear before instance elements + { + bool isValueType = IsValueTypeOrValueTypeByRef(nullability.Type); + if (!isValueType) + { + var state = NullabilityState.Unknown; + if (!parser.ParseNullableState(index, ref state)) + { + return false; + } + + nullability.ReadState = state; + nullability.WriteState = state; + } + + if (!isValueType || (Nullable.GetUnderlyingType(nullability.Type) ?? nullability.Type).IsGenericType) + { + index++; + } + + if (nullability.GenericTypeArguments.Length > 0) + { + foreach (NullabilityInfo genericTypeArgumentNullability in nullability.GenericTypeArguments) + { + _ = TryPopulateNullabilityInfo(genericTypeArgumentNullability, parser, ref index); + } + } + else if (nullability.ElementType is { } elementTypeNullability) + { + _ = TryPopulateNullabilityInfo(elementTypeNullability, parser, ref index); + } + + return true; + } + + private static NullabilityState TranslateByte(object? value) + { + return value is byte b ? TranslateByte(b) : NullabilityState.Unknown; + } + + private static NullabilityState TranslateByte(byte b) => + b switch + { + 1 => NullabilityState.NotNull, + 2 => NullabilityState.Nullable, + _ => NullabilityState.Unknown + }; + + private static bool IsValueTypeOrValueTypeByRef(Type type) => + type.IsValueType || ((type.IsByRef || type.IsPointer) && type.GetElementType()!.IsValueType); + + private readonly struct NullableAttributeStateParser + { + private static readonly object UnknownByte = (byte)0; + + private readonly object? _nullableAttributeArgument; + + public NullableAttributeStateParser(object? nullableAttributeArgument) + { + _nullableAttributeArgument = nullableAttributeArgument; + } + + public static NullableAttributeStateParser Unknown => new(UnknownByte); + + public bool ParseNullableState(int index, ref NullabilityState state) + { + switch (_nullableAttributeArgument) + { + case byte b: + state = TranslateByte(b); + return true; + case ReadOnlyCollection args + when index < args.Count && args[index].Value is byte elementB: + state = TranslateByte(elementB); + return true; + default: + return false; + } + } + } + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs new file mode 100644 index 00000000000..1ee573a0020 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable IDE1006 // Naming Styles +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace System.Reflection +{ + /// + /// Polyfills for System.Private.CoreLib internals. + /// + [ExcludeFromCodeCoverage] + internal static class NullabilityInfoHelpers + { + public static MemberInfo GetMemberWithSameMetadataDefinitionAs(Type type, MemberInfo member) + { + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + foreach (var info in type.GetMembers(all)) + { + if (info.HasSameMetadataDefinitionAs(member)) + { + return info; + } + } + + throw new MissingMemberException(type.FullName, member.Name); + } + + // https://github.com/dotnet/runtime/blob/main/src/coreclr/System.Private.CoreLib/src/System/Reflection/MemberInfo.Internal.cs + public static bool HasSameMetadataDefinitionAs(this MemberInfo target, MemberInfo other) + { + return target.MetadataToken == other.MetadataToken && + target.Module.Equals(other.Module); + } + + // https://github.com/dotnet/runtime/issues/23493 + public static bool IsGenericMethodParameter(this Type target) + { + return target.IsGenericParameter && + target.DeclaringMethod != null; + } + } +} +#endif diff --git a/src/Shared/JsonSchemaExporter/README.md b/src/Shared/JsonSchemaExporter/README.md new file mode 100644 index 00000000000..1a4d13c5841 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/README.md @@ -0,0 +1,11 @@ +# JsonSchemaExporter + +Provides a polyfill for the [.NET 9 `JsonSchemaExporter` component](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/extract-schema) that is compatible with all supported targets using System.Text.Json version 8. + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/src/Shared/Shared.csproj b/src/Shared/Shared.csproj index f6cbb03ea83..58ec4eda535 100644 --- a/src/Shared/Shared.csproj +++ b/src/Shared/Shared.csproj @@ -12,7 +12,7 @@ true true true - true + true true true true @@ -33,6 +33,10 @@ + + + + diff --git a/test/Shared/JsonSchemaExporter/Helpers.cs b/test/Shared/JsonSchemaExporter/Helpers.cs new file mode 100644 index 00000000000..a925c1721f0 --- /dev/null +++ b/test/Shared/JsonSchemaExporter/Helpers.cs @@ -0,0 +1,91 @@ +// 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.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Json.Schema; +using Json.Schema.Generation; +using Xunit.Sdk; + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +internal static partial class Helpers +{ + public static void AssertValidJsonSchema(Type type, string? expectedJsonSchema, JsonNode actualJsonSchema) + { + // If an expected schema is provided, use that. Otherwise, generate a schema from the type. + JsonNode? expectedJsonSchemaNode = expectedJsonSchema != null + ? JsonNode.Parse(expectedJsonSchema, documentOptions: new() { CommentHandling = JsonCommentHandling.Skip }) + : JsonSerializer.SerializeToNode(new JsonSchemaBuilder().FromType(type), Context.Default.JsonSchema); + + // Trim the $schema property from actual schema since it's not included by the generator. + (actualJsonSchema as JsonObject)?.Remove("$schema"); + + if (!JsonNode.DeepEquals(expectedJsonSchemaNode, actualJsonSchema)) + { + throw new XunitException($""" + Generated schema does not match the expected specification. + Expected: + {FormatJson(expectedJsonSchemaNode)} + Actual: + {FormatJson(actualJsonSchema)} + """); + } + } + + public static void AssertDocumentMatchesSchema(JsonNode schema, JsonNode? instance) + { + EvaluationResults results = EvaluateSchemaCore(schema, instance); + if (!results.IsValid) + { + IEnumerable errors = results.Details + .Where(d => d.HasErrors) + .SelectMany(d => d.Errors!.Select(error => $"Path:${d.InstanceLocation} {error.Key}:{error.Value}")); + + throw new XunitException($""" + Instance JSON document does not match the specified schema. + Schema: + {FormatJson(schema)} + Instance: + {FormatJson(instance)} + Errors: + {string.Join(Environment.NewLine, errors)} + """); + } + } + + public static void AssertDoesNotMatchSchema(JsonNode schema, JsonNode? instance) + { + EvaluationResults results = EvaluateSchemaCore(schema, instance); + if (results.IsValid) + { + throw new XunitException($""" + Instance JSON document matches the specified schema. + Schema: + {FormatJson(schema)} + Instance: + {FormatJson(instance)} + """); + } + } + + private static EvaluationResults EvaluateSchemaCore(JsonNode schema, JsonNode? instance) + { + JsonSchema jsonSchema = JsonSerializer.Deserialize(schema, Context.Default.JsonSchema)!; + EvaluationOptions options = new() { OutputFormat = OutputFormat.List }; + return jsonSchema.Evaluate(instance, options); + } + + private static string FormatJson(JsonNode? node) => + JsonSerializer.Serialize(node, Context.Default.JsonNode!); + + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(JsonNode))] + [JsonSerializable(typeof(JsonSchema))] + [JsonSourceGenerationOptions(WriteIndented = true)] + private partial class Context : JsonSerializerContext; +} diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs new file mode 100644 index 00000000000..1d2b6caa74e --- /dev/null +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Schema; +using Xunit; + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +public static class JsonSchemaExporterConfigurationTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public static void JsonSchemaExporterOptions_DefaultValues(bool useSingleton) + { + JsonSchemaExporterOptions configuration = useSingleton ? JsonSchemaExporterOptions.Default : new(); + Assert.False(configuration.TreatNullObliviousAsNonNullable); + Assert.Null(configuration.TransformSchemaNode); + } + + [Fact] + public static void JsonSchemaExporterOptions_Singleton_ReturnsSameInstance() + { + Assert.Same(JsonSchemaExporterOptions.Default, JsonSchemaExporterOptions.Default); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public static void JsonSchemaExporterOptions_TreatNullObliviousAsNonNullable(bool treatNullObliviousAsNonNullable) + { + JsonSchemaExporterOptions configuration = new() { TreatNullObliviousAsNonNullable = treatNullObliviousAsNonNullable }; + Assert.Equal(treatNullObliviousAsNonNullable, configuration.TreatNullObliviousAsNonNullable); + } +} diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs new file mode 100644 index 00000000000..d526025d5ba --- /dev/null +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs @@ -0,0 +1,148 @@ +// 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.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +#if !NET9_0_OR_GREATER +using System.Xml.Linq; +#endif +using Xunit; + +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable xUnit1000 // Test classes must be public + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +public abstract class JsonSchemaExporterTests +{ + protected abstract JsonSerializerOptions Options { get; } + + [Theory] + [MemberData(nameof(TestTypes.GetTestData), MemberType = typeof(TestTypes))] + public void TestTypes_GeneratesExpectedJsonSchema(ITestData testData) + { + JsonSerializerOptions options = testData.Options is { } opts + ? new(opts) { TypeInfoResolver = Options.TypeInfoResolver } + : Options; + + JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); + Helpers.AssertValidJsonSchema(testData.Type, testData.ExpectedJsonSchema, schema); + } + + [Theory] + [MemberData(nameof(TestTypes.GetTestDataUsingAllValues), MemberType = typeof(TestTypes))] + public void TestTypes_SerializedValueMatchesGeneratedSchema(ITestData testData) + { + JsonSerializerOptions options = testData.Options is { } opts + ? new(opts) { TypeInfoResolver = Options.TypeInfoResolver } + : Options; + + JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); + JsonNode? instance = JsonSerializer.SerializeToNode(testData.Value, testData.Type, options); + Helpers.AssertDocumentMatchesSchema(schema, instance); + } + + [Theory] + [InlineData(typeof(string), "string")] + [InlineData(typeof(int[]), "array")] + [InlineData(typeof(Dictionary), "object")] + [InlineData(typeof(TestTypes.SimplePoco), "object")] + public void TreatNullObliviousAsNonNullable_True_MarksAllReferenceTypesAsNonNullable(Type referenceType, string expectedType) + { + Assert.True(!referenceType.IsValueType); + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; + JsonNode schema = Options.GetJsonSchemaAsNode(referenceType, config); + JsonValue type = Assert.IsAssignableFrom(schema["type"]); + Assert.Equal(expectedType, (string)type!); + } + + [Theory] + [InlineData(typeof(int), "integer")] + [InlineData(typeof(double), "number")] + [InlineData(typeof(bool), "boolean")] + [InlineData(typeof(ImmutableArray), "array")] + [InlineData(typeof(TestTypes.StructDictionary), "object")] + [InlineData(typeof(TestTypes.SimpleRecordStruct), "object")] + public void TreatNullObliviousAsNonNullable_True_DoesNotImpactNonReferenceTypes(Type referenceType, string expectedType) + { + Assert.True(referenceType.IsValueType); + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; + JsonNode schema = Options.GetJsonSchemaAsNode(referenceType, config); + JsonValue value = Assert.IsAssignableFrom(schema["type"]); + Assert.Equal(expectedType, (string)value!); + } + +#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/108764 gets backported + [Fact] + public void CanGenerateXElementSchema() + { + JsonNode schema = Options.GetJsonSchemaAsNode(typeof(XElement)); + Assert.True(schema.ToJsonString().Length < 100_000); + } +#endif + + [Fact] + public void TreatNullObliviousAsNonNullable_True_DoesNotImpactObjectType() + { + var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; + JsonNode schema = Options.GetJsonSchemaAsNode(typeof(object), config); + Assert.False(schema is JsonObject jObj && jObj.ContainsKey("type")); + } + + [Fact] + public void TypeWithDisallowUnmappedMembers_AdditionalPropertiesFailValidation() + { + JsonNode schema = Options.GetJsonSchemaAsNode(typeof(TestTypes.PocoDisallowingUnmappedMembers)); + JsonNode? jsonWithUnmappedProperties = JsonNode.Parse("""{ "UnmappedProperty" : {} }"""); + Helpers.AssertDoesNotMatchSchema(schema, jsonWithUnmappedProperties); + } + + [Fact] + public void GetJsonSchema_NullInputs_ThrowsArgumentNullException() + { + Assert.Throws(() => ((JsonSerializerOptions)null!).GetJsonSchemaAsNode(typeof(int))); + Assert.Throws(() => Options.GetJsonSchemaAsNode(type: null!)); + Assert.Throws(() => ((JsonTypeInfo)null!).GetJsonSchemaAsNode()); + } + + [Fact] + public void GetJsonSchema_NoResolver_ThrowInvalidOperationException() + { + var options = new JsonSerializerOptions(); + Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(int))); + } + + [Fact] + public void MaxDepth_SetToZero_NonTrivialSchema_ThrowsInvalidOperationException() + { + JsonSerializerOptions options = new(Options) { MaxDepth = 1 }; + var ex = Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(TestTypes.SimplePoco))); + Assert.Contains("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting.", ex.Message); + } + + [Fact] + public void ReferenceHandlePreserve_Enabled_ThrowsNotSupportedException() + { + var options = new JsonSerializerOptions(Options) { ReferenceHandler = ReferenceHandler.Preserve }; + options.MakeReadOnly(); + + var ex = Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(TestTypes.SimplePoco))); + Assert.Contains("ReferenceHandler.Preserve", ex.Message); + } +} + +public sealed class ReflectionJsonSchemaExporterTests : JsonSchemaExporterTests +{ + protected override JsonSerializerOptions Options => JsonSerializerOptions.Default; +} + +public sealed class SourceGenJsonSchemaExporterTests : JsonSchemaExporterTests +{ + protected override JsonSerializerOptions Options => TestTypes.TestTypesContext.Default.Options; +} diff --git a/test/Shared/JsonSchemaExporter/TestData.cs b/test/Shared/JsonSchemaExporter/TestData.cs new file mode 100644 index 00000000000..6b2c9d841a3 --- /dev/null +++ b/test/Shared/JsonSchemaExporter/TestData.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Schema; + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +internal sealed record TestData( + T? Value, + IEnumerable? AdditionalValues = null, + [StringSyntax("Json")] string? ExpectedJsonSchema = null, + JsonSchemaExporterOptions? ExporterOptions = null, + JsonSerializerOptions? Options = null) + : ITestData +{ + public Type Type => typeof(T); + object? ITestData.Value => Value; + object? ITestData.ExporterOptions => ExporterOptions; + + IEnumerable ITestData.GetTestDataForAllValues() + { + yield return this; + + if (AdditionalValues != null) + { + foreach (T? value in AdditionalValues) + { + yield return this with { Value = value, AdditionalValues = null }; + } + } + } +} + +public interface ITestData +{ + Type Type { get; } + + object? Value { get; } + + /// + /// Gets the expected JSON schema for the value. + /// Fall back to JsonSchemaGenerator as the source of truth if null. + /// + string? ExpectedJsonSchema { get; } + + object? ExporterOptions { get; } + + JsonSerializerOptions? Options { get; } + + IEnumerable GetTestDataForAllValues(); +} diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs new file mode 100644 index 00000000000..4615143aa78 --- /dev/null +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -0,0 +1,1293 @@ +// 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; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; +using System.Xml.Linq; + +#pragma warning disable SA1118 // Parameter should not span multiple lines +#pragma warning disable JSON001 // Comments not allowed +#pragma warning disable S2344 // Enumeration type names should not have "Flags" or "Enum" suffixes +#pragma warning disable SA1502 // Element should not be on a single line +#pragma warning disable SA1136 // Enum values should be on separate lines +#pragma warning disable SA1133 // Do not combine attributes +#pragma warning disable S3604 // Member initializer values should not be redundant +#pragma warning disable SA1515 // Single-line comment should be preceded by blank line +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable +#pragma warning disable S1121 // Assignments should not be made from within sub-expressions +#pragma warning disable IDE0073 // The file header is missing or not located at the top of the file +#pragma warning disable SA1402 // File may only contain a single type + +namespace Microsoft.Extensions.AI.JsonSchemaExporter; + +public static partial class TestTypes +{ + public static IEnumerable GetTestData() => GetTestDataCore().Select(t => new object[] { t }); + + public static IEnumerable GetTestDataUsingAllValues() => + GetTestDataCore() + .SelectMany(t => t.GetTestDataForAllValues()) + .Select(t => new object[] { t }); + + public static IEnumerable GetTestDataCore() + { + // Primitives and built-in types + yield return new TestData( + Value: new(), + AdditionalValues: [null, 42, false, 3.14, 3.14M, new int[] { 1, 2, 3 }, new SimpleRecord(1, "str", false, 3.14)], + ExpectedJsonSchema: "true"); + + yield return new TestData(true); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(42); + yield return new TestData(1.2f); + yield return new TestData(3.14159d); + yield return new TestData(3.14159M); +#if NET7_0_OR_GREATER + yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); +#endif +#if NET6_0_OR_GREATER + yield return new TestData((Half)3.141, ExpectedJsonSchema: """{"type":"number"}"""); +#endif + yield return new TestData("I am a string", ExpectedJsonSchema: """{"type":["string","null"]}"""); + yield return new TestData('c', ExpectedJsonSchema: """{"type":"string","minLength":1,"maxLength":1}"""); + yield return new TestData( + Value: [1, 2, 3], + AdditionalValues: [[]], + ExpectedJsonSchema: """{"type":["string","null"]}"""); + + yield return new TestData>(new byte[] { 1, 2, 3 }, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData>(new byte[] { 1, 2, 3 }, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData( + Value: new(2021, 1, 1), + AdditionalValues: [DateTime.MinValue, DateTime.MaxValue]); + + yield return new TestData( + Value: new(new DateTime(2021, 1, 1), TimeSpan.Zero), + AdditionalValues: [DateTimeOffset.MinValue, DateTimeOffset.MaxValue], + ExpectedJsonSchema: """{"type":"string","format": "date-time"}"""); + + yield return new TestData( + Value: new(hours: 5, minutes: 13, seconds: 3), + AdditionalValues: [TimeSpan.MinValue, TimeSpan.MaxValue], + ExpectedJsonSchema: """{"$comment": "Represents a System.TimeSpan value.", "type":"string", "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"}"""); + +#if NET6_0_OR_GREATER + yield return new TestData(new(2021, 1, 1), ExpectedJsonSchema: """{"type":"string","format": "date"}"""); + yield return new TestData(new(hour: 22, minute: 30, second: 33, millisecond: 100), ExpectedJsonSchema: """{"type":"string","format": "time"}"""); +#endif + yield return new TestData(Guid.Empty); + yield return new TestData(new("http://example.com"), ExpectedJsonSchema: """{"type":["string","null"], "format":"uri"}"""); + yield return new TestData(new(1, 2, 3, 4), ExpectedJsonSchema: """{"$comment":"Represents a version string.", "type":["string","null"],"pattern":"^\\d+(\\.\\d+){1,3}$"}"""); + yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]"""), ExpectedJsonSchema: "true"); + yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]""").RootElement, ExpectedJsonSchema: "true"); + yield return new TestData(JsonNode.Parse("""[{ "x" : 42 }]"""), ExpectedJsonSchema: "true"); + yield return new TestData((JsonValue)42, ExpectedJsonSchema: "true"); + yield return new TestData(new() { ["x"] = 42 }, ExpectedJsonSchema: """{"type":["object","null"]}"""); + yield return new TestData([1, 2, 3], ExpectedJsonSchema: """{"type":["array","null"]}"""); + + // Enum types + yield return new TestData(IntEnum.A, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(StringEnum.A, ExpectedJsonSchema: """{"enum": ["A","B","C"]}"""); + yield return new TestData(FlagsStringEnum.A, ExpectedJsonSchema: """{"type":"string"}"""); + + // Nullable types + yield return new TestData(true, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["boolean","null"]}"""); + yield return new TestData(42, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["integer","null"]}"""); + yield return new TestData(3.14, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["number","null"]}"""); + yield return new TestData(Guid.Empty, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["string","null"],"format":"uuid"}"""); + yield return new TestData(JsonDocument.Parse("{}").RootElement, AdditionalValues: [null], ExpectedJsonSchema: "true"); + yield return new TestData(IntEnum.A, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["integer","null"]}"""); + yield return new TestData(StringEnum.A, AdditionalValues: [null], ExpectedJsonSchema: """{"enum":["A","B","C",null]}"""); + yield return new TestData( + new(1, "two", true, 3.14), + AdditionalValues: [null], + ExpectedJsonSchema: """ + { + "type":["object","null"], + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + } + """); + + // User-defined POCOs + yield return new TestData( + Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, + AdditionalValues: [new() { String = "str", StringNullable = null }, null], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + } + } + """); + + // 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 }, + AdditionalValues: [new() { String = "str", StringNullable = null }], + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + } + } + """, + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); + + yield return new TestData( + Value: new(1, "two", true, 3.14), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X","Y","Z","W"] + } + """); + + yield return new TestData( + Value: new(1, "two", true, 3.14), + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + } + } + """); + + yield return new TestData( + Value: new(1, "two", true, 3.14, StringEnum.A), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X1": { "type": "integer" }, + "X2": { "type": "string" }, + "X3": { "type": "boolean" }, + "X4": { "type": "number" }, + "X5": { "enum": ["A", "B", "C"] }, + "Y1": { "type": "integer", "default": 42 }, + "Y2": { "type": "string", "default": "str" }, + "Y3": { "type": "boolean", "default": true }, + "Y4": { "type": "number", "default": 0 }, + "Y5": { "enum": ["A", "B", "C"], "default": "A" } + }, + "required": ["X1", "X2", "X3", "X4", "X5"] + } + """); + + yield return new TestData( + new() { X = "str1", Y = "str2" }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Y": { "type": "string" }, + "Z": { "type": "integer" }, + "X": { "type": "string" } + }, + "required": [ "Y", "Z", "X" ] + } + """); + + yield return new TestData( + new() { X = 1, Y = 2 }, + ExpectedJsonSchema: """ + { + "type": [ "object", "null" ], + "properties": { + "X": { "type": "integer" } + } + } + """); + yield return new TestData( + Value: new() { IntegerProperty = 1, StringProperty = "str" }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "int": { "type": "integer" }, + "str": { "type": [ "string", "null"] } + } + } + """); + + yield return new TestData( + Value: new() { X = 1 }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { "X": { "type": ["string","integer"], "pattern": "^-?(?:0|[1-9]\\d*)$" } } + } + """); + + yield return new TestData( + Value: new() { X = 1, Y = 2, Z = 3 }, + AdditionalValues: [ + new() { X = 1, Y = double.NaN, Z = 3 }, + new() { X = 1, Y = double.PositiveInfinity, Z = 3 }, + new() { X = 1, Y = double.NegativeInfinity, Z = 3 }, + ], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X": { "type": ["string", "integer"], "pattern": "^-?(?:0|[1-9]\\d*)$" }, + "Y": { + "anyOf": [ + { "type": "number" }, + { "enum": ["NaN", "Infinity", "-Infinity"]} + ] + }, + "Z": { "type": ["string", "integer"], "pattern": "^-?(?:0|[1-9]\\d*)$" }, + "W" : { "type": "number" } + } + } + """); + + yield return new TestData( + Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, + AdditionalValues: [null, new() { Value = 1, Next = null }], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": { "type": "integer" }, + "Next": { + "type": ["object","null"], + "properties": { + "Value": { "type": "integer" }, + "Next": { "$ref": "#/properties/Next" } + } + } + } + } + """); + + // 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 } } }, + AdditionalValues: [new() { Value = 1, Next = null }], + ExpectedJsonSchema: """ + { + "type": "object", + "properties": { + "Value": { "type": "integer" }, + "Next": { + "type": ["object","null"], + "properties": { + "Value": { "type": "integer" }, + "Next": { "$ref": "#/properties/Next" } + } + } + } + } + """, + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); + +#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/108764 gets backported + SimpleRecord recordValue = new(42, "str", true, 3.14); + yield return new TestData( + Value: new() { Value1 = recordValue, Value2 = recordValue, ArrayValue = [recordValue], ListValue = [recordValue] }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value1": { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X", "Y", "Z", "W"] + }, + /* The same type on a different property is repeated to + account for potential metadata resolved from attributes. */ + "Value2": { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X", "Y", "Z", "W"] + }, + /* This collection element is the first occurrence + of the type without contextual metadata. */ + "ListValue": { + "type": ["array","null"], + "items": { + "type": ["object","null"], + "properties": { + "X": { "type": "integer" }, + "Y": { "type": "string" }, + "Z": { "type": "boolean" }, + "W": { "type": "number" } + }, + "required": ["X", "Y", "Z", "W"] + } + }, + /* This collection element is the second occurrence + of the type which points to the first occurrence. */ + "ArrayValue": { + "type": ["array","null"], + "items": { + "$ref": "#/properties/ListValue/items" + } + } + } + } + """); +#endif + + yield return new TestData( + Value: new() { X = 42 }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X": { + "type": "integer" + } + } + } + """); + + yield return new TestData(new() { Value = 42 }, ExpectedJsonSchema: "true"); + yield return new TestData(new() { Value = 42 }, ExpectedJsonSchema: """{"type":["object","null"],"properties":{"Value":true}}"""); + yield return new TestData( + Value: new() + { + IntEnum = IntEnum.A, + StringEnum = StringEnum.B, + IntEnumUsingStringConverter = IntEnum.A, + NullableIntEnumUsingStringConverter = IntEnum.B, + StringEnumUsingIntConverter = StringEnum.A, + NullableStringEnumUsingIntConverter = StringEnum.B + }, + AdditionalValues: [ + new() + { + IntEnum = (IntEnum)int.MaxValue, + StringEnum = StringEnum.A, + IntEnumUsingStringConverter = IntEnum.A, + NullableIntEnumUsingStringConverter = null, + StringEnumUsingIntConverter = (StringEnum)int.MaxValue, + NullableStringEnumUsingIntConverter = null + }, + ], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "IntEnum": { "type": "integer" }, + "StringEnum": { "enum": [ "A", "B", "C" ] }, + "IntEnumUsingStringConverter": { "enum": [ "A", "B", "C" ] }, + "NullableIntEnumUsingStringConverter": { "enum": [ "A", "B", "C", null ] }, + "StringEnumUsingIntConverter": { "type": "integer" }, + "NullableStringEnumUsingIntConverter": { "type": [ "integer", "null" ] } + } + } + """); + + var recordStruct = new SimpleRecordStruct(42, "str", true, 3.14); + yield return new TestData( + Value: new() { Struct = recordStruct, NullableStruct = null }, + AdditionalValues: [new() { Struct = recordStruct, NullableStruct = recordStruct }], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Struct": { + "type": "object", + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + }, + "NullableStruct": { + "type": ["object","null"], + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + } + } + } + """); + + yield return new TestData( + Value: new() { NullableStruct = null, Struct = recordStruct }, + AdditionalValues: [new() { NullableStruct = recordStruct, Struct = recordStruct }], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "NullableStruct": { + "type": ["object","null"], + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + }, + "Struct": { + "type": "object", + "properties": { + "X": {"type":"integer"}, + "Y": {"type":"string"}, + "Z": {"type":"boolean"}, + "W": {"type":"number"} + } + } + } + } + """); + + yield return new TestData( + Value: new() { Name = "name", ExtensionData = new() { ["x"] = 42 } }, + ExpectedJsonSchema: """{"type":["object","null"],"properties":{"Name":{"type":["string","null"]}}}"""); + + yield return new TestData( + Value: new() { Name = "name", Age = 42 }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Name": {"type":["string","null"]}, + "Age": {"type":"integer"} + }, + "additionalProperties": false + } + """); + +#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/107545 gets backported + // Global JsonUnmappedMemberHandling.Disallow setting + yield return new TestData( + Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, + AdditionalValues: [new() { String = "str", StringNullable = null }, null], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + }, + "additionalProperties": false + } + """, + Options: new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }); +#endif + + yield return new TestData( + Value: new() { MaybeNull = null!, AllowNull = null, NotNull = null, DisallowNull = null!, NotNullDisallowNull = "str" }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "MaybeNull": {"type":["string","null"]}, + "AllowNull": {"type":["string","null"]}, + "NotNull": {"type":["string","null"]}, + "DisallowNull": {"type":["string","null"]}, + "NotNullDisallowNull": {"type":"string"} + } + } + """); + + yield return new TestData( + Value: new(allowNull: null, disallowNull: "str"), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "AllowNull": {"type":["string","null"]}, + "DisallowNull": {"type":"string"} + }, + "required": ["AllowNull", "DisallowNull"] + } + """); + + yield return new TestData( + Value: new(null), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": {"type":["string","null"]} + }, + "required": ["Value"] + } + """); + + yield return new TestData( + Value: new(), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "X1": {"type":"string", "default": "str" }, + "X2": {"type":"integer", "default": 42 }, + "X3": {"type":"boolean", "default": true }, + "X4": {"type":"number", "default": 0 }, + "X5": {"enum":["A","B","C"], "default": "A" }, + "X6": {"type":["string","null"], "default": "str" }, + "X7": {"type":["integer","null"], "default": 42 }, + "X8": {"type":["boolean","null"], "default": true }, + "X9": {"type":["number","null"], "default": 0 }, + "X10": {"enum":["A","B","C", null], "default": "A" } + } + } + """); + + yield return new TestData>( + Value: new(null!), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": {"type":["string","null"]} + }, + "required": ["Value"] + } + """); + + yield return new TestData( + Value: new PocoWithPolymorphism.DerivedPocoStringDiscriminator { BaseValue = 42, DerivedValue = "derived" }, + AdditionalValues: [ + new PocoWithPolymorphism.DerivedPocoNoDiscriminator { BaseValue = 42, DerivedValue = "derived" }, + new PocoWithPolymorphism.DerivedPocoIntDiscriminator { BaseValue = 42, DerivedValue = "derived" }, + new PocoWithPolymorphism.DerivedCollection { BaseValue = 42 }, + new PocoWithPolymorphism.DerivedDictionary { BaseValue = 42 }, + ], + + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "anyOf": [ + { + "properties": { + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + } + }, + { + "properties": { + "$type": {"const":"derivedPoco"}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":42}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedCollection"}, + "$values": { + "type": "array", + "items": {"type":"integer"} + } + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedDictionary"} + }, + "additionalProperties":{"type": "integer"}, + "required": ["$type"] + } + ] + } + """); + + yield return new TestData( + Value: new NonAbstractClassWithSingleDerivedType(), + AdditionalValues: [new NonAbstractClassWithSingleDerivedType.Derived()], + ExpectedJsonSchema: """ + { + "type": ["object","null"] + } + """); + +#if !NET9_0 // Disable until https://github.com/microsoft/semantic-kernel/issues/8983 gets backported to .NET 9 + yield return new TestData( + Value: new(value: null), + AdditionalValues: [new(true), new(42), new(""), new(new object()), new(Array.Empty())], + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "Value": { "default": null } + } + } + """); +#endif + + yield return new TestData( + Value: new(), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "PolymorphicValue": { + "type": "object", + "anyOf": [ + { + "properties": { + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + } + }, + { + "properties": { + "$type": {"const":"derivedPoco"}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":42}, + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedCollection"}, + "$values": { + "type": "array", + "items": {"type":"integer"} + } + }, + "required": ["$type"] + }, + { + "properties": { + "$type": {"const":"derivedDictionary"} + }, + "additionalProperties":{"type": "integer"}, + "required": ["$type"] + } + ] + }, + "DerivedValue1": { + "type": "object", + "properties": { + "BaseValue": { + "type": "integer" + }, + "DerivedValue": { + "type": [ + "string", + "null" + ] + } + } + }, + "DerivedValue2": { + "type": "object", + "properties": { + "BaseValue": {"type":"integer"}, + "DerivedValue": {"type":["string","null"]} + } + } + } + } + """); + + yield return new TestData( + Value: new("string", -1), + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "properties": { + "StringValue": {"type":"string","pattern":"\\w+"}, + "IntValue": {"type":"integer","default":42} + }, + "required": ["StringValue","IntValue"] + } + """, + ExporterOptions: new() + { + TransformSchemaNode = static (ctx, schema) => + { + if (ctx.PropertyInfo is null || schema is not JsonObject jObj) + { + return schema; + } + + if (ctx.ResolveAttribute() is { } attr) + { + jObj["default"] = JsonSerializer.SerializeToNode(attr.Value); + } + + if (ctx.ResolveAttribute() is { } regexAttr) + { + jObj["pattern"] = regexAttr.Pattern; + } + + return jObj; + } + }); + + // Collection types + yield return new TestData([1, 2, 3], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"integer"}}"""); + yield return new TestData>([false, true, false], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"boolean"}}"""); + yield return new TestData>(["one", "two", "three"], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":["string","null"]}}"""); + yield return new TestData>(new([1.1, 2.2, 3.3]), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"number"}}"""); + yield return new TestData>(new(['x', '2', '+']), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":1}}"""); + yield return new TestData>(ImmutableArray.Create(1, 2, 3), ExpectedJsonSchema: """{"type":"array","items":{"type":"integer"}}"""); + yield return new TestData>(ImmutableList.Create("one", "two", "three"), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":["string","null"]}}"""); + yield return new TestData>(ImmutableQueue.Create(false, false, true), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"boolean"}}"""); + yield return new TestData([1, "two", 3.14], ExpectedJsonSchema: """{"type":["array","null"]}"""); + yield return new TestData([1, "two", 3.14], ExpectedJsonSchema: """{"type":["array","null"]}"""); + + // Dictionary types + yield return new TestData>( + Value: new() { ["one"] = 1, ["two"] = 2, ["three"] = 3 }, + ExpectedJsonSchema: """{"type":["object","null"],"additionalProperties":{"type": "integer"}}"""); + + yield return new TestData>( + Value: new([new("one", 1), new("two", 2), new("three", 3)]), + ExpectedJsonSchema: """{"type":"object","additionalProperties":{"type": "integer"}}"""); + + yield return new TestData>( + Value: new() { [1] = "one", [2] = "two", [3] = "three" }, + ExpectedJsonSchema: """{"type":["object","null"],"additionalProperties":{"type": ["string","null"]}}"""); + + yield return new TestData>( + Value: new() + { + ["one"] = new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, + ["two"] = new() { String = "string", StringNullable = null, Int = 42, Double = 3.14, Boolean = true }, + ["three"] = new() { String = "string", StringNullable = null, Int = 42, Double = 3.14, Boolean = true }, + }, + ExpectedJsonSchema: """ + { + "type": ["object","null"], + "additionalProperties": { + "properties": { + "String": { "type": "string" }, + "StringNullable": { "type": ["string", "null"] }, + "Int": { "type": "integer" }, + "Double": { "type": "number" }, + "Boolean": { "type": "boolean" } + }, + "type": ["object","null"] + } + } + """); + + yield return new TestData>( + Value: new() { ["one"] = 1, ["two"] = "two", ["three"] = 3.14 }, + ExpectedJsonSchema: """{"type":["object","null"]}"""); + + yield return new TestData( + Value: new() { ["one"] = 1, ["two"] = "two", ["three"] = 3.14 }, + ExpectedJsonSchema: """{"type":["object","null"]}"""); + } + + public enum IntEnum { A, B, C } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum StringEnum { A, B, C } + + [Flags, JsonConverter(typeof(JsonStringEnumConverter))] + public enum FlagsStringEnum { A = 1, B = 2, C = 4 } + + public class SimplePoco + { + public string String { get; set; } = "default"; + public string? StringNullable { get; set; } + + public int Int { get; set; } + public double Double { get; set; } + public bool Boolean { get; set; } + } + + public record SimpleRecord(int X, string Y, bool Z, double W); + public record struct SimpleRecordStruct(int X, string Y, bool Z, double W); + + public record RecordWithOptionalParameters( + [property: Description("required integer")] int X1, string X2, bool X3, double X4, [Description("required string enum")] StringEnum X5, + [property: Description("optional integer")] int Y1 = 42, string Y2 = "str", bool Y3 = true, double Y4 = 0, [Description("optional string enum")] StringEnum Y5 = StringEnum.A); + + public class PocoWithRequiredMembers + { + [JsonInclude] + public required string X; + + public required string Y { get; set; } + + [JsonRequired] + public int Z { get; set; } + } + + public class PocoWithIgnoredMembers + { + public int X { get; set; } + + [JsonIgnore] + public int Y { get; set; } + } + + public class PocoWithCustomNaming + { + [JsonPropertyName("int")] + public int IntegerProperty { get; set; } + + [JsonPropertyName("str")] + public string? StringProperty { get; set; } + } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public class PocoWithCustomNumberHandling + { + public int X { get; set; } + } + + public class PocoWithCustomNumberHandlingOnProperties + { + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public int X { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowNamedFloatingPointLiterals)] + public double Y { get; set; } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + public int Z { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowNamedFloatingPointLiterals)] + public decimal W { get; set; } + } + + public class PocoWithRecursiveMembers + { + public int Value { get; init; } + public PocoWithRecursiveMembers? Next { get; init; } + } + + public class PocoWithNonRecursiveDuplicateOccurrences + { + public SimpleRecord? Value1 { get; set; } + public SimpleRecord? Value2 { get; set; } + public List? ListValue { get; set; } + public SimpleRecord[]? ArrayValue { get; set; } + } + + [Description("The type description")] + public class PocoWithDescription + { + [Description("The property description")] + public int X { get; set; } + } + + [JsonConverter(typeof(CustomConverter))] + public class PocoWithCustomConverter + { + public int Value { get; set; } + + public class CustomConverter : JsonConverter + { + public override PocoWithCustomConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new PocoWithCustomConverter { Value = reader.GetInt32() }; + + public override void Write(Utf8JsonWriter writer, PocoWithCustomConverter value, JsonSerializerOptions options) => + writer.WriteNumberValue(value.Value); + } + } + + public class PocoWithCustomPropertyConverter + { + [JsonConverter(typeof(CustomConverter))] + public int Value { get; set; } + + public class CustomConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => int.Parse(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); + } + } + + public class PocoWithEnums + { + public IntEnum IntEnum { get; init; } + public StringEnum StringEnum { get; init; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public IntEnum IntEnumUsingStringConverter { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public IntEnum? NullableIntEnumUsingStringConverter { get; set; } + + [JsonConverter(typeof(JsonNumberEnumConverter))] + public StringEnum StringEnumUsingIntConverter { get; set; } + + [JsonConverter(typeof(JsonNumberEnumConverter))] + public StringEnum? NullableStringEnumUsingIntConverter { get; set; } + } + + public class PocoWithStructFollowedByNullableStruct + { + public SimpleRecordStruct? NullableStruct { get; set; } + public SimpleRecordStruct Struct { get; set; } + } + + public class PocoWithNullableStructFollowedByStruct + { + public SimpleRecordStruct? NullableStruct { get; set; } + public SimpleRecordStruct Struct { get; set; } + } + + public class PocoWithExtensionDataProperty + { + public string? Name { get; set; } + + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + public class PocoDisallowingUnmappedMembers + { + public string? Name { get; set; } + public int Age { get; set; } + } + + public class PocoWithNullableAnnotationAttributes + { + [MaybeNull] + public string MaybeNull { get; set; } + + [AllowNull] + public string AllowNull { get; set; } + + [NotNull] + public string? NotNull { get; set; } + + [DisallowNull] + public string? DisallowNull { get; set; } + + [NotNull, DisallowNull] + public string? NotNullDisallowNull { get; set; } = ""; + } + + public class PocoWithNullableAnnotationAttributesOnConstructorParams([AllowNull] string allowNull, [DisallowNull] string? disallowNull) + { + public string AllowNull { get; } = allowNull!; + public string DisallowNull { get; } = disallowNull; + } + + public class PocoWithNullableConstructorParameter(string? value) + { + public string Value { get; } = value!; + } + + public class PocoWithOptionalConstructorParams( + string x1 = "str", int x2 = 42, bool x3 = true, double x4 = 0, StringEnum x5 = StringEnum.A, + string? x6 = "str", int? x7 = 42, bool? x8 = true, double? x9 = 0, StringEnum? x10 = StringEnum.A) + { + public string X1 { get; } = x1; + public int X2 { get; } = x2; + public bool X3 { get; } = x3; + public double X4 { get; } = x4; + public StringEnum X5 { get; } = x5; + + public string? X6 { get; } = x6; + public int? X7 { get; } = x7; + public bool? X8 { get; } = x8; + public double? X9 { get; } = x9; + public StringEnum? X10 { get; } = x10; + } + + // Regression test for https://github.com/dotnet/runtime/issues/92487 + public class GenericPocoWithNullableConstructorParameter(T value) + { + [NotNull] + public T Value { get; } = value!; + } + + [JsonDerivedType(typeof(DerivedPocoNoDiscriminator))] + [JsonDerivedType(typeof(DerivedPocoStringDiscriminator), "derivedPoco")] + [JsonDerivedType(typeof(DerivedPocoIntDiscriminator), 42)] + [JsonDerivedType(typeof(DerivedCollection), "derivedCollection")] + [JsonDerivedType(typeof(DerivedDictionary), "derivedDictionary")] + public abstract class PocoWithPolymorphism + { + public int BaseValue { get; set; } + + public class DerivedPocoNoDiscriminator : PocoWithPolymorphism + { + public string? DerivedValue { get; set; } + } + + public class DerivedPocoStringDiscriminator : PocoWithPolymorphism + { + public string? DerivedValue { get; set; } + } + + public class DerivedPocoIntDiscriminator : PocoWithPolymorphism + { + public string? DerivedValue { get; set; } + } + + public class DerivedCollection : PocoWithPolymorphism, IEnumerable + { + public IEnumerator GetEnumerator() => Enumerable.Repeat(BaseValue, 1).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public class DerivedDictionary : PocoWithPolymorphism, IReadOnlyDictionary + { + public int this[string key] => key == nameof(BaseValue) ? BaseValue : throw new KeyNotFoundException(); + public IEnumerable Keys => [nameof(BaseValue)]; + public IEnumerable Values => [BaseValue]; + public int Count => 1; + public bool ContainsKey(string key) => key == nameof(BaseValue); + public bool TryGetValue(string key, out int value) => key == nameof(BaseValue) ? (value = BaseValue) == BaseValue : (value = 0) == 0; + public IEnumerator> GetEnumerator() => Enumerable.Repeat(new KeyValuePair(nameof(BaseValue), BaseValue), 1).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + + [JsonDerivedType(typeof(NonAbstractClassWithSingleDerivedType.Derived))] + public class NonAbstractClassWithSingleDerivedType + { + public class Derived : NonAbstractClassWithSingleDerivedType; + } + + public class PocoCombiningPolymorphicTypeAndDerivedTypes + { + public PocoWithPolymorphism PolymorphicValue { get; set; } = new PocoWithPolymorphism.DerivedPocoNoDiscriminator { DerivedValue = "derived" }; + public PocoWithPolymorphism.DerivedPocoNoDiscriminator DerivedValue1 { get; set; } = new() { DerivedValue = "derived" }; + public PocoWithPolymorphism.DerivedPocoStringDiscriminator DerivedValue2 { get; set; } = new() { DerivedValue = "derived" }; + } + + public class ClassWithComponentModelAttributes + { + public ClassWithComponentModelAttributes(string stringValue, [DefaultValue(42)] int intValue) + { + StringValue = stringValue; + IntValue = intValue; + } + + [RegularExpression(@"\w+")] + public string StringValue { get; } + + public int IntValue { get; } + } + + public class ClassWithOptionalObjectParameter(object? value = null) + { + public object? Value { get; } = value; + } + + public readonly struct StructDictionary(IEnumerable> values) + : IReadOnlyDictionary + where TKey : notnull + { + private readonly IReadOnlyDictionary _dictionary = values.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + public TValue this[TKey key] => _dictionary[key]; + public IEnumerable Keys => _dictionary.Keys; + public IEnumerable Values => _dictionary.Values; + public int Count => _dictionary.Count; + public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key); + public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); +#if NETCOREAPP + 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); +#endif + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_dictionary).GetEnumerator(); + } + + [JsonSerializable(typeof(object))] + [JsonSerializable(typeof(bool))] + [JsonSerializable(typeof(byte))] + [JsonSerializable(typeof(ushort))] + [JsonSerializable(typeof(uint))] + [JsonSerializable(typeof(ulong))] + [JsonSerializable(typeof(sbyte))] + [JsonSerializable(typeof(short))] + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(long))] + [JsonSerializable(typeof(float))] + [JsonSerializable(typeof(double))] + [JsonSerializable(typeof(decimal))] +#if NET7_0_OR_GREATER + [JsonSerializable(typeof(UInt128))] + [JsonSerializable(typeof(Int128))] +#endif +#if NET6_0_OR_GREATER + [JsonSerializable(typeof(Half))] +#endif + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(char))] + [JsonSerializable(typeof(byte[]))] + [JsonSerializable(typeof(Memory))] + [JsonSerializable(typeof(ReadOnlyMemory))] + [JsonSerializable(typeof(DateTime))] + [JsonSerializable(typeof(DateTimeOffset))] + [JsonSerializable(typeof(TimeSpan))] +#if NET6_0_OR_GREATER + [JsonSerializable(typeof(DateOnly))] + [JsonSerializable(typeof(TimeOnly))] +#endif + [JsonSerializable(typeof(Guid))] + [JsonSerializable(typeof(Uri))] + [JsonSerializable(typeof(Version))] + [JsonSerializable(typeof(JsonDocument))] + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(JsonNode))] + [JsonSerializable(typeof(JsonValue))] + [JsonSerializable(typeof(JsonObject))] + [JsonSerializable(typeof(JsonArray))] + // Enum types + [JsonSerializable(typeof(IntEnum))] + [JsonSerializable(typeof(StringEnum))] + [JsonSerializable(typeof(FlagsStringEnum))] + // Nullable types + [JsonSerializable(typeof(bool?))] + [JsonSerializable(typeof(int?))] + [JsonSerializable(typeof(double?))] + [JsonSerializable(typeof(Guid?))] + [JsonSerializable(typeof(JsonElement?))] + [JsonSerializable(typeof(IntEnum?))] + [JsonSerializable(typeof(StringEnum?))] + [JsonSerializable(typeof(SimpleRecordStruct?))] + // User-defined POCOs + [JsonSerializable(typeof(SimplePoco))] + [JsonSerializable(typeof(SimpleRecord))] + [JsonSerializable(typeof(SimpleRecordStruct))] + [JsonSerializable(typeof(RecordWithOptionalParameters))] + [JsonSerializable(typeof(PocoWithRequiredMembers))] + [JsonSerializable(typeof(PocoWithIgnoredMembers))] + [JsonSerializable(typeof(PocoWithCustomNaming))] + [JsonSerializable(typeof(PocoWithCustomNumberHandling))] + [JsonSerializable(typeof(PocoWithCustomNumberHandlingOnProperties))] + [JsonSerializable(typeof(PocoWithRecursiveMembers))] + [JsonSerializable(typeof(PocoWithNonRecursiveDuplicateOccurrences))] + [JsonSerializable(typeof(PocoWithDescription))] + [JsonSerializable(typeof(PocoWithCustomConverter))] + [JsonSerializable(typeof(PocoWithCustomPropertyConverter))] + [JsonSerializable(typeof(PocoWithEnums))] + [JsonSerializable(typeof(PocoWithStructFollowedByNullableStruct))] + [JsonSerializable(typeof(PocoWithNullableStructFollowedByStruct))] + [JsonSerializable(typeof(PocoWithExtensionDataProperty))] + [JsonSerializable(typeof(PocoDisallowingUnmappedMembers))] + [JsonSerializable(typeof(PocoWithNullableAnnotationAttributes))] + [JsonSerializable(typeof(PocoWithNullableAnnotationAttributesOnConstructorParams))] + [JsonSerializable(typeof(PocoWithNullableConstructorParameter))] + [JsonSerializable(typeof(PocoWithOptionalConstructorParams))] + [JsonSerializable(typeof(GenericPocoWithNullableConstructorParameter))] + [JsonSerializable(typeof(PocoWithPolymorphism))] + [JsonSerializable(typeof(NonAbstractClassWithSingleDerivedType))] + [JsonSerializable(typeof(PocoCombiningPolymorphicTypeAndDerivedTypes))] + [JsonSerializable(typeof(ClassWithComponentModelAttributes))] + [JsonSerializable(typeof(ClassWithOptionalObjectParameter))] + // Collection types + [JsonSerializable(typeof(int[]))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(HashSet))] + [JsonSerializable(typeof(Queue))] + [JsonSerializable(typeof(Stack))] + [JsonSerializable(typeof(ImmutableArray))] + [JsonSerializable(typeof(ImmutableList))] + [JsonSerializable(typeof(ImmutableQueue))] + [JsonSerializable(typeof(object[]))] + [JsonSerializable(typeof(System.Collections.ArrayList))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(SortedDictionary))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(Hashtable))] + [JsonSerializable(typeof(StructDictionary))] + [JsonSerializable(typeof(XElement))] + public partial class TestTypesContext : JsonSerializerContext; + + 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 +#if NET9_0_OR_GREATER + GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? + GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? +#else + GetAttrs(ctx.PropertyAttributeProvider) ?? + GetAttrs(ctx.ParameterInfo) ?? +#endif + GetAttrs(ctx.TypeInfo.Type); + + static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => + (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); + } +} diff --git a/test/Shared/Shared.Tests.csproj b/test/Shared/Shared.Tests.csproj index d7bfa1801e2..dc2a46d60d9 100644 --- a/test/Shared/Shared.Tests.csproj +++ b/test/Shared/Shared.Tests.csproj @@ -5,16 +5,23 @@ - $(NoWarn);CA1716 + $(NoWarn);CA1716;S104 $(TestNetCoreTargetFrameworks) $(TestNetCoreTargetFrameworks)$(ConditionalNet462) + + true + true + + + + From ce9a807d4b8f84f851648d0fe14099c862b4fa07 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 31 Oct 2024 17:20:53 +0000 Subject: [PATCH 089/190] Plug JsonSchemaExporter test data to the AIJsonUtilities tests (#5590) * Plug JsonSchemaExporter test data to the AIJsonUtilities tests * Update src/LegacySupport/DiagnosticAttributes/README.md * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs * Address feedback. --- eng/packages/TestOnly.props | 1 - .../Utilities/AIJsonUtilities.Schema.cs | 17 ++- ...ft.Extensions.AI.Abstractions.Tests.csproj | 13 +- .../{ => Utilities}/AIJsonUtilitiesTests.cs | 33 ++++- .../JsonSchemaExporterTests.cs | 6 +- .../{Helpers.cs => SchemaTestHelpers.cs} | 17 +-- test/Shared/JsonSchemaExporter/TestData.cs | 26 +++- test/Shared/JsonSchemaExporter/TestTypes.cs | 121 +++++++++--------- test/Shared/Shared.Tests.csproj | 2 +- 9 files changed, 142 insertions(+), 94 deletions(-) rename test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/{ => Utilities}/AIJsonUtilitiesTests.cs (79%) rename test/Shared/JsonSchemaExporter/{Helpers.cs => SchemaTestHelpers.cs} (75%) diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 78772d87d09..f6753c9c14d 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -21,7 +21,6 @@ - diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs index cd33a2557af..b555148df8b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs @@ -14,6 +14,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Schema; +using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; #pragma warning disable S1121 // Assignments should not be made from within sub-expressions @@ -282,7 +283,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) // 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. // STJ represents .NET integer types as ["string", "integer"], which will then lead to an error. - if (TypeIsArrayContainingInteger(objSchema)) + if (TypeIsIntegerWithStringNumberHandling(ctx, objSchema)) { // We don't want to emit any array for "type". In this case we know it contains "integer" // so reduce the type to that alone, assuming it's the most specific type. @@ -351,17 +352,21 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) } } - private static bool TypeIsArrayContainingInteger(JsonObject schema) + private static bool TypeIsIntegerWithStringNumberHandling(JsonSchemaExporterContext ctx, JsonObject schema) { - if (schema["type"] is JsonArray typeArray) + if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray typeArray) { - foreach (var entry in typeArray) + int count = 0; + foreach (JsonNode? entry in typeArray) { - if (entry?.GetValueKind() == JsonValueKind.String && entry.GetValue() == "integer") + if (entry?.GetValueKind() is JsonValueKind.String && + entry.GetValue() is "integer" or "string") { - return true; + count++; } } + + return count == typeArray.Count; } return false; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index 0d4d5fbfa96..911ce1b2bf8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -5,16 +5,27 @@ - $(NoWarn);CA1063;CA1861;CA2201;VSTHRD003 + $(NoWarn);CA1063;CA1861;CA2201;VSTHRD003;S104 true + true + true + true true + true + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs similarity index 79% rename from test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs rename to test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index d7ff5c6783e..52f9cad246d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -3,7 +3,9 @@ using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using Microsoft.Extensions.AI.JsonSchemaExporter; using Xunit; namespace Microsoft.Extensions.AI; @@ -130,7 +132,7 @@ public static void ResolveParameterJsonSchema_ReturnsExpectedValue() } [Fact] - public static void ResolveParameterJsonSchema_TreatsIntegralTypesAsInteger_EvenWithAllowReadingFromString() + public static void CreateParameterJsonSchema_TreatsIntegralTypesAsInteger_EvenWithAllowReadingFromString() { JsonElement expected = JsonDocument.Parse(""" { @@ -160,9 +162,36 @@ public enum MyEnumValue } [Fact] - public static void ResolveJsonSchema_CanBeBoolean() + public static void CreateJsonSchema_CanBeBoolean() { JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(object)); Assert.Equal(JsonValueKind.True, schema.ValueKind); } + + [Theory] + [MemberData(nameof(TestTypes.GetTestDataUsingAllValues), MemberType = typeof(TestTypes))] + public static void CreateJsonSchema_ValidateWithTestData(ITestData testData) + { + // Stress tests the schema generation method using types from the JsonSchemaExporter test battery. + + JsonSerializerOptions options = testData.Options is { } opts + ? new(opts) { TypeInfoResolver = TestTypes.TestTypesContext.Default } + : TestTypes.TestTypesContext.Default.Options; + + JsonElement schema = AIJsonUtilities.CreateJsonSchema(testData.Type, serializerOptions: options); + JsonNode? schemaAsNode = JsonSerializer.SerializeToNode(schema, options); + + Assert.NotNull(schemaAsNode); + Assert.Equal(testData.ExpectedJsonSchema.GetValueKind(), schemaAsNode.GetValueKind()); + + if (testData.Value is null || testData.WritesNumbersAsStrings) + { + // By design, our generated schema does not accept null root values + // or numbers formatted as strings, so we skip schema validation. + return; + } + + JsonNode? serializedValue = JsonSerializer.SerializeToNode(testData.Value, testData.Type, options); + SchemaTestHelpers.AssertDocumentMatchesSchema(schemaAsNode, serializedValue); + } } diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs index d526025d5ba..93207a7167f 100644 --- a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs @@ -32,7 +32,7 @@ public void TestTypes_GeneratesExpectedJsonSchema(ITestData testData) : Options; JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); - Helpers.AssertValidJsonSchema(testData.Type, testData.ExpectedJsonSchema, schema); + SchemaTestHelpers.AssertEqualJsonSchema(testData.ExpectedJsonSchema, schema); } [Theory] @@ -45,7 +45,7 @@ public void TestTypes_SerializedValueMatchesGeneratedSchema(ITestData testData) JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); JsonNode? instance = JsonSerializer.SerializeToNode(testData.Value, testData.Type, options); - Helpers.AssertDocumentMatchesSchema(schema, instance); + SchemaTestHelpers.AssertDocumentMatchesSchema(schema, instance); } [Theory] @@ -100,7 +100,7 @@ public void TypeWithDisallowUnmappedMembers_AdditionalPropertiesFailValidation() { JsonNode schema = Options.GetJsonSchemaAsNode(typeof(TestTypes.PocoDisallowingUnmappedMembers)); JsonNode? jsonWithUnmappedProperties = JsonNode.Parse("""{ "UnmappedProperty" : {} }"""); - Helpers.AssertDoesNotMatchSchema(schema, jsonWithUnmappedProperties); + SchemaTestHelpers.AssertDoesNotMatchSchema(schema, jsonWithUnmappedProperties); } [Fact] diff --git a/test/Shared/JsonSchemaExporter/Helpers.cs b/test/Shared/JsonSchemaExporter/SchemaTestHelpers.cs similarity index 75% rename from test/Shared/JsonSchemaExporter/Helpers.cs rename to test/Shared/JsonSchemaExporter/SchemaTestHelpers.cs index a925c1721f0..02e659a27aa 100644 --- a/test/Shared/JsonSchemaExporter/Helpers.cs +++ b/test/Shared/JsonSchemaExporter/SchemaTestHelpers.cs @@ -8,29 +8,20 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Json.Schema; -using Json.Schema.Generation; using Xunit.Sdk; namespace Microsoft.Extensions.AI.JsonSchemaExporter; -internal static partial class Helpers +internal static partial class SchemaTestHelpers { - public static void AssertValidJsonSchema(Type type, string? expectedJsonSchema, JsonNode actualJsonSchema) + public static void AssertEqualJsonSchema(JsonNode expectedJsonSchema, JsonNode actualJsonSchema) { - // If an expected schema is provided, use that. Otherwise, generate a schema from the type. - JsonNode? expectedJsonSchemaNode = expectedJsonSchema != null - ? JsonNode.Parse(expectedJsonSchema, documentOptions: new() { CommentHandling = JsonCommentHandling.Skip }) - : JsonSerializer.SerializeToNode(new JsonSchemaBuilder().FromType(type), Context.Default.JsonSchema); - - // Trim the $schema property from actual schema since it's not included by the generator. - (actualJsonSchema as JsonObject)?.Remove("$schema"); - - if (!JsonNode.DeepEquals(expectedJsonSchemaNode, actualJsonSchema)) + if (!JsonNode.DeepEquals(expectedJsonSchema, actualJsonSchema)) { throw new XunitException($""" Generated schema does not match the expected specification. Expected: - {FormatJson(expectedJsonSchemaNode)} + {FormatJson(expectedJsonSchema)} Actual: {FormatJson(actualJsonSchema)} """); diff --git a/test/Shared/JsonSchemaExporter/TestData.cs b/test/Shared/JsonSchemaExporter/TestData.cs index 6b2c9d841a3..0254a62b144 100644 --- a/test/Shared/JsonSchemaExporter/TestData.cs +++ b/test/Shared/JsonSchemaExporter/TestData.cs @@ -5,26 +5,40 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Schema; namespace Microsoft.Extensions.AI.JsonSchemaExporter; internal sealed record TestData( T? Value, + [StringSyntax(StringSyntaxAttribute.Json)] string ExpectedJsonSchema, IEnumerable? AdditionalValues = null, - [StringSyntax("Json")] string? ExpectedJsonSchema = null, JsonSchemaExporterOptions? ExporterOptions = null, - JsonSerializerOptions? Options = null) + JsonSerializerOptions? Options = null, + bool WritesNumbersAsStrings = false) : ITestData { + private static readonly JsonDocumentOptions _schemaParseOptions = new() { CommentHandling = JsonCommentHandling.Skip }; + public Type Type => typeof(T); object? ITestData.Value => Value; object? ITestData.ExporterOptions => ExporterOptions; + JsonNode ITestData.ExpectedJsonSchema { get; } = + JsonNode.Parse(ExpectedJsonSchema, documentOptions: _schemaParseOptions) + ?? throw new ArgumentNullException("schema must not be null"); IEnumerable ITestData.GetTestDataForAllValues() { yield return this; + if (default(T) is null && + ExporterOptions is { TreatNullObliviousAsNonNullable: false } && + Value is not null) + { + yield return this with { Value = default }; + } + if (AdditionalValues != null) { foreach (T? value in AdditionalValues) @@ -41,15 +55,13 @@ public interface ITestData object? Value { get; } - /// - /// Gets the expected JSON schema for the value. - /// Fall back to JsonSchemaGenerator as the source of truth if null. - /// - string? ExpectedJsonSchema { get; } + JsonNode ExpectedJsonSchema { get; } object? ExporterOptions { get; } JsonSerializerOptions? Options { get; } + bool WritesNumbersAsStrings { get; } + IEnumerable GetTestDataForAllValues(); } diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index 4615143aa78..f8c54fdb178 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -45,40 +45,41 @@ public static IEnumerable GetTestDataCore() // Primitives and built-in types yield return new TestData( Value: new(), - AdditionalValues: [null, 42, false, 3.14, 3.14M, new int[] { 1, 2, 3 }, new SimpleRecord(1, "str", false, 3.14)], + AdditionalValues: [42, false, 3.14, 3.14M, new int[] { 1, 2, 3 }, new SimpleRecord(1, "str", false, 3.14)], ExpectedJsonSchema: "true"); - yield return new TestData(true); - yield return new TestData(42); - yield return new TestData(42); - yield return new TestData(42); - yield return new TestData(42); - yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); - yield return new TestData(42); - yield return new TestData(42); - yield return new TestData(42); - yield return new TestData(1.2f); - yield return new TestData(3.14159d); - yield return new TestData(3.14159M); + yield return new TestData(true, """{"type":"boolean"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(1.2f, """{"type":"number"}"""); + yield return new TestData(3.14159d, """{"type":"number"}"""); + yield return new TestData(3.14159M, """{"type":"number"}"""); #if NET7_0_OR_GREATER - yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); - yield return new TestData(42, ExpectedJsonSchema: """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); + yield return new TestData(42, """{"type":"integer"}"""); #endif #if NET6_0_OR_GREATER - yield return new TestData((Half)3.141, ExpectedJsonSchema: """{"type":"number"}"""); + yield return new TestData((Half)3.141, """{"type":"number"}"""); #endif - yield return new TestData("I am a string", ExpectedJsonSchema: """{"type":["string","null"]}"""); - yield return new TestData('c', ExpectedJsonSchema: """{"type":"string","minLength":1,"maxLength":1}"""); + yield return new TestData("I am a string", """{"type":["string","null"]}"""); + yield return new TestData('c', """{"type":"string","minLength":1,"maxLength":1}"""); yield return new TestData( Value: [1, 2, 3], AdditionalValues: [[]], ExpectedJsonSchema: """{"type":["string","null"]}"""); - yield return new TestData>(new byte[] { 1, 2, 3 }, ExpectedJsonSchema: """{"type":"string"}"""); - yield return new TestData>(new byte[] { 1, 2, 3 }, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData>(new byte[] { 1, 2, 3 }, """{"type":"string"}"""); + yield return new TestData>(new byte[] { 1, 2, 3 }, """{"type":"string"}"""); yield return new TestData( Value: new(2021, 1, 1), - AdditionalValues: [DateTime.MinValue, DateTime.MaxValue]); + AdditionalValues: [DateTime.MinValue, DateTime.MaxValue], + ExpectedJsonSchema: """{"type":"string","format": "date-time"}"""); yield return new TestData( Value: new(new DateTime(2021, 1, 1), TimeSpan.Zero), @@ -91,35 +92,34 @@ public static IEnumerable GetTestDataCore() ExpectedJsonSchema: """{"$comment": "Represents a System.TimeSpan value.", "type":"string", "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"}"""); #if NET6_0_OR_GREATER - yield return new TestData(new(2021, 1, 1), ExpectedJsonSchema: """{"type":"string","format": "date"}"""); - yield return new TestData(new(hour: 22, minute: 30, second: 33, millisecond: 100), ExpectedJsonSchema: """{"type":"string","format": "time"}"""); + yield return new TestData(new(2021, 1, 1), """{"type":"string","format": "date"}"""); + yield return new TestData(new(hour: 22, minute: 30, second: 33, millisecond: 100), """{"type":"string","format": "time"}"""); #endif - yield return new TestData(Guid.Empty); - yield return new TestData(new("http://example.com"), ExpectedJsonSchema: """{"type":["string","null"], "format":"uri"}"""); - yield return new TestData(new(1, 2, 3, 4), ExpectedJsonSchema: """{"$comment":"Represents a version string.", "type":["string","null"],"pattern":"^\\d+(\\.\\d+){1,3}$"}"""); - yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]"""), ExpectedJsonSchema: "true"); - yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]""").RootElement, ExpectedJsonSchema: "true"); - yield return new TestData(JsonNode.Parse("""[{ "x" : 42 }]"""), ExpectedJsonSchema: "true"); - yield return new TestData((JsonValue)42, ExpectedJsonSchema: "true"); - yield return new TestData(new() { ["x"] = 42 }, ExpectedJsonSchema: """{"type":["object","null"]}"""); - yield return new TestData([1, 2, 3], ExpectedJsonSchema: """{"type":["array","null"]}"""); + yield return new TestData(Guid.Empty, """{"type":"string","format":"uuid"}"""); + yield return new TestData(new("http://example.com"), """{"type":["string","null"], "format":"uri"}"""); + yield return new TestData(new(1, 2, 3, 4), """{"$comment":"Represents a version string.", "type":["string","null"],"pattern":"^\\d+(\\.\\d+){1,3}$"}"""); + yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]"""), "true"); + yield return new TestData(JsonDocument.Parse("""[{ "x" : 42 }]""").RootElement, "true"); + yield return new TestData(JsonNode.Parse("""[{ "x" : 42 }]"""), "true"); + yield return new TestData((JsonValue)42, "true"); + yield return new TestData(new() { ["x"] = 42 }, """{"type":["object","null"]}"""); + yield return new TestData([1, 2, 3], """{"type":["array","null"]}"""); // Enum types - yield return new TestData(IntEnum.A, ExpectedJsonSchema: """{"type":"integer"}"""); - yield return new TestData(StringEnum.A, ExpectedJsonSchema: """{"enum": ["A","B","C"]}"""); - yield return new TestData(FlagsStringEnum.A, ExpectedJsonSchema: """{"type":"string"}"""); + yield return new TestData(IntEnum.A, """{"type":"integer"}"""); + yield return new TestData(StringEnum.A, """{"enum": ["A","B","C"]}"""); + yield return new TestData(FlagsStringEnum.A, """{"type":"string"}"""); // Nullable types - yield return new TestData(true, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["boolean","null"]}"""); - yield return new TestData(42, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["integer","null"]}"""); - yield return new TestData(3.14, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["number","null"]}"""); - yield return new TestData(Guid.Empty, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["string","null"],"format":"uuid"}"""); - yield return new TestData(JsonDocument.Parse("{}").RootElement, AdditionalValues: [null], ExpectedJsonSchema: "true"); - yield return new TestData(IntEnum.A, AdditionalValues: [null], ExpectedJsonSchema: """{"type":["integer","null"]}"""); - yield return new TestData(StringEnum.A, AdditionalValues: [null], ExpectedJsonSchema: """{"enum":["A","B","C",null]}"""); + yield return new TestData(true, """{"type":["boolean","null"]}"""); + yield return new TestData(42, """{"type":["integer","null"]}"""); + yield return new TestData(3.14, """{"type":["number","null"]}"""); + yield return new TestData(Guid.Empty, """{"type":["string","null"],"format":"uuid"}"""); + yield return new TestData(JsonDocument.Parse("{}").RootElement, "true"); + yield return new TestData(IntEnum.A, """{"type":["integer","null"]}"""); + yield return new TestData(StringEnum.A, """{"enum":["A","B","C",null]}"""); yield return new TestData( new(1, "two", true, 3.14), - AdditionalValues: [null], ExpectedJsonSchema: """ { "type":["object","null"], @@ -135,7 +135,7 @@ public static IEnumerable GetTestDataCore() // User-defined POCOs yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, - AdditionalValues: [new() { String = "str", StringNullable = null }, null], + AdditionalValues: [new() { String = "str", StringNullable = null }], ExpectedJsonSchema: """ { "type": ["object","null"], @@ -269,6 +269,7 @@ public static IEnumerable GetTestDataCore() new() { X = 1, Y = double.PositiveInfinity, Z = 3 }, new() { X = 1, Y = double.NegativeInfinity, Z = 3 }, ], + WritesNumbersAsStrings: true, ExpectedJsonSchema: """ { "type": ["object","null"], @@ -288,7 +289,7 @@ public static IEnumerable GetTestDataCore() yield return new TestData( Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, - AdditionalValues: [null, new() { Value = 1, Next = null }], + AdditionalValues: [new() { Value = 1, Next = null }], ExpectedJsonSchema: """ { "type": ["object","null"], @@ -397,8 +398,8 @@ of the type which points to the first occurrence. */ } """); - yield return new TestData(new() { Value = 42 }, ExpectedJsonSchema: "true"); - yield return new TestData(new() { Value = 42 }, ExpectedJsonSchema: """{"type":["object","null"],"properties":{"Value":true}}"""); + yield return new TestData(new() { Value = 42 }, "true"); + yield return new TestData(new() { Value = 42 }, """{"type":["object","null"],"properties":{"Value":true}}"""); yield return new TestData( Value: new() { @@ -495,7 +496,7 @@ of the type which points to the first occurrence. */ yield return new TestData( Value: new() { Name = "name", ExtensionData = new() { ["x"] = 42 } }, - ExpectedJsonSchema: """{"type":["object","null"],"properties":{"Name":{"type":["string","null"]}}}"""); + """{"type":["object","null"],"properties":{"Name":{"type":["string","null"]}}}"""); yield return new TestData( Value: new() { Name = "name", Age = 42 }, @@ -514,7 +515,7 @@ of the type which points to the first occurrence. */ // Global JsonUnmappedMemberHandling.Disallow setting yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, - AdditionalValues: [new() { String = "str", StringNullable = null }, null], + AdditionalValues: [new() { String = "str", StringNullable = null }], ExpectedJsonSchema: """ { "type": ["object","null"], @@ -793,16 +794,16 @@ of the type which points to the first occurrence. */ }); // Collection types - yield return new TestData([1, 2, 3], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"integer"}}"""); - yield return new TestData>([false, true, false], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"boolean"}}"""); - yield return new TestData>(["one", "two", "three"], ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":["string","null"]}}"""); - yield return new TestData>(new([1.1, 2.2, 3.3]), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"number"}}"""); - yield return new TestData>(new(['x', '2', '+']), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":1}}"""); - yield return new TestData>(ImmutableArray.Create(1, 2, 3), ExpectedJsonSchema: """{"type":"array","items":{"type":"integer"}}"""); - yield return new TestData>(ImmutableList.Create("one", "two", "three"), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":["string","null"]}}"""); - yield return new TestData>(ImmutableQueue.Create(false, false, true), ExpectedJsonSchema: """{"type":["array","null"],"items":{"type":"boolean"}}"""); - yield return new TestData([1, "two", 3.14], ExpectedJsonSchema: """{"type":["array","null"]}"""); - yield return new TestData([1, "two", 3.14], ExpectedJsonSchema: """{"type":["array","null"]}"""); + yield return new TestData([1, 2, 3], """{"type":["array","null"],"items":{"type":"integer"}}"""); + yield return new TestData>([false, true, false], """{"type":["array","null"],"items":{"type":"boolean"}}"""); + yield return new TestData>(["one", "two", "three"], """{"type":["array","null"],"items":{"type":["string","null"]}}"""); + yield return new TestData>(new([1.1, 2.2, 3.3]), """{"type":["array","null"],"items":{"type":"number"}}"""); + yield return new TestData>(new(['x', '2', '+']), """{"type":["array","null"],"items":{"type":"string","minLength":1,"maxLength":1}}"""); + yield return new TestData>(ImmutableArray.Create(1, 2, 3), """{"type":"array","items":{"type":"integer"}}"""); + yield return new TestData>(ImmutableList.Create("one", "two", "three"), """{"type":["array","null"],"items":{"type":["string","null"]}}"""); + yield return new TestData>(ImmutableQueue.Create(false, false, true), """{"type":["array","null"],"items":{"type":"boolean"}}"""); + yield return new TestData([1, "two", 3.14], """{"type":["array","null"]}"""); + yield return new TestData([1, "two", 3.14], """{"type":["array","null"]}"""); // Dictionary types yield return new TestData>( @@ -1278,7 +1279,7 @@ public partial class TestTypesContext : JsonSerializerContext; // 2. Parameter-level attributes and // 3. Type-level attributes. return -#if NET9_0_OR_GREATER +#if NET9_0_OR_GREATER || !TESTS_JSON_SCHEMA_EXPORTER_POLYFILL GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? #else diff --git a/test/Shared/Shared.Tests.csproj b/test/Shared/Shared.Tests.csproj index dc2a46d60d9..456e50f67a9 100644 --- a/test/Shared/Shared.Tests.csproj +++ b/test/Shared/Shared.Tests.csproj @@ -2,6 +2,7 @@ Microsoft.Shared.Test Unit tests for Microsoft.Shared + $(DefineConstants);TESTS_JSON_SCHEMA_EXPORTER_POLYFILL @@ -22,6 +23,5 @@ - From 23b073207f4f2a87660a8b36098927991a3355ad Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Fri, 1 Nov 2024 11:20:36 +0000 Subject: [PATCH 090/190] Improve JsonSchemaExporter trimmer safety. (#5591) * Improve JsonSchemaExporter trimmer safety. * Remove var * Address feedback. * Remove DynamicallyAccessedMemberTypes.All * Extract reflection helpers into separate file and remove a number of warning suppressions. * Re-enable failing tests that were patched in .NET 9 --- .../JsonSchemaExporter.ReflectionHelpers.cs | 427 ++++++++++++++++++ .../JsonSchemaExporter/JsonSchemaExporter.cs | 421 ++--------------- src/Shared/Shared.csproj | 1 + .../JsonSchemaExporterTests.cs | 1 - test/Shared/JsonSchemaExporter/TestTypes.cs | 3 - 5 files changed, 475 insertions(+), 378 deletions(-) create mode 100644 src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs new file mode 100644 index 00000000000..481e5f75753 --- /dev/null +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs @@ -0,0 +1,427 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET9_0_OR_GREATER +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +#if !NET +using System.Linq; +#endif +using System.Reflection; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace System.Text.Json.Schema; + +internal static partial class JsonSchemaExporter +{ + private static class ReflectionHelpers + { + private const BindingFlags AllInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private static PropertyInfo? _jsonTypeInfo_ElementType; + private static PropertyInfo? _jsonPropertyInfo_MemberName; + private static FieldInfo? _nullableConverter_ElementConverter_Generic; + private static FieldInfo? _enumConverter_Options_Generic; + private static FieldInfo? _enumConverter_NamingPolicy_Generic; + + public static bool IsBuiltInConverter(JsonConverter converter) => + converter.GetType().Assembly == typeof(JsonConverter).Assembly; + + public static 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"); + + // Uses reflection to access the element type encapsulated by a JsonTypeInfo. + if (_jsonTypeInfo_ElementType is null) + { + PropertyInfo? elementTypeProperty = typeof(JsonTypeInfo).GetProperty("ElementType", AllInstance); + _jsonTypeInfo_ElementType = Throw.IfNull(elementTypeProperty); + } + + return (Type)_jsonTypeInfo_ElementType.GetValue(typeInfo)!; + } + + public static string? GetMemberName(JsonPropertyInfo propertyInfo) + { + // Uses reflection to the member name encapsulated by a JsonPropertyInfo. + if (_jsonPropertyInfo_MemberName is null) + { + PropertyInfo? memberName = typeof(JsonPropertyInfo).GetProperty("MemberName", AllInstance); + _jsonPropertyInfo_MemberName = Throw.IfNull(memberName); + } + + return (string?)_jsonPropertyInfo_MemberName.GetValue(propertyInfo); + } + + public static JsonConverter GetElementConverter(JsonConverter nullableConverter) + { + // Uses reflection to access the element converter encapsulated by a nullable converter. + if (_nullableConverter_ElementConverter_Generic is null) + { + FieldInfo? genericFieldInfo = Type + .GetType("System.Text.Json.Serialization.Converters.NullableConverter`1, System.Text.Json")! + .GetField("_elementConverter", AllInstance); + + _nullableConverter_ElementConverter_Generic = Throw.IfNull(genericFieldInfo); + } + + Type converterType = nullableConverter.GetType(); + var thisFieldInfo = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_nullableConverter_ElementConverter_Generic); + return (JsonConverter)thisFieldInfo.GetValue(nullableConverter)!; + } + + public static void GetEnumConverterConfig(JsonConverter enumConverter, out JsonNamingPolicy? namingPolicy, out bool allowString) + { + // Uses reflection to access configuration encapsulated by an enum converter. + if (_enumConverter_Options_Generic is null) + { + FieldInfo? genericFieldInfo = Type + .GetType("System.Text.Json.Serialization.Converters.EnumConverter`1, System.Text.Json")! + .GetField("_converterOptions", AllInstance); + + _enumConverter_Options_Generic = Throw.IfNull(genericFieldInfo); + } + + if (_enumConverter_NamingPolicy_Generic is null) + { + FieldInfo? genericFieldInfo = Type + .GetType("System.Text.Json.Serialization.Converters.EnumConverter`1, System.Text.Json")! + .GetField("_namingPolicy", AllInstance); + + _enumConverter_NamingPolicy_Generic = Throw.IfNull(genericFieldInfo); + } + + const int EnumConverterOptionsAllowStrings = 1; + Type converterType = enumConverter.GetType(); + var converterOptionsField = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_enumConverter_Options_Generic); + var namingPolicyField = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_enumConverter_NamingPolicy_Generic); + + namingPolicy = (JsonNamingPolicy?)namingPolicyField.GetValue(enumConverter); + int converterOptions = (int)converterOptionsField.GetValue(enumConverter)!; + allowString = (converterOptions & EnumConverterOptionsAllowStrings) != 0; + } + + // The .NET 8 source generator doesn't populate attribute providers for properties + // cf. https://github.com/dotnet/runtime/issues/100095 + // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property + // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 + public static ICustomAttributeProvider? ResolveAttributeProvider( + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties | + DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] + Type? declaringType, + JsonPropertyInfo? propertyInfo) + { + if (declaringType is null || propertyInfo is null) + { + return null; + } + + if (propertyInfo.AttributeProvider is { } provider) + { + return provider; + } + + string? memberName = ReflectionHelpers.GetMemberName(propertyInfo); + if (memberName is not null) + { + return (MemberInfo?)declaringType.GetProperty(memberName, AllInstance) ?? + declaringType.GetField(memberName, AllInstance); + } + + return null; + } + + // Resolves the parameters of the deserialization constructor for a type, if they exist. + public static Func? ResolveJsonConstructorParameterMapper( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] + Type type, + JsonTypeInfo typeInfo) + { + Debug.Assert(type == typeInfo.Type, "The declaring type must match the typeInfo type."); + Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object, "Should only be passed object JSON kinds."); + + if (typeInfo.Properties.Count > 0 && + typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used + TryGetDeserializationConstructor(type, useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) + { + ParameterInfo[]? parameters = ctor?.GetParameters(); + if (parameters?.Length > 0) + { + Dictionary dict = new(parameters.Length); + foreach (ParameterInfo parameter in parameters) + { + if (parameter.Name is not null) + { + // We don't care about null parameter names or conflicts since they + // would have already been rejected by JsonTypeInfo exporterOptions. + dict[new(parameter.Name, parameter.ParameterType)] = parameter; + } + } + + return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; + } + } + + return null; + } + + // Resolves the nullable reference type annotations for a property or field, + // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. + public static NullabilityInfo GetMemberNullability(NullabilityInfoContext context, MemberInfo memberInfo) + { + Debug.Assert(memberInfo is PropertyInfo or FieldInfo, "Member must be property or field."); + return memberInfo is PropertyInfo prop + ? context.Create(prop) + : context.Create((FieldInfo)memberInfo); + } + + public static NullabilityState GetParameterNullability(NullabilityInfoContext context, ParameterInfo parameterInfo) + { +#if NET8_0 + // Workaround for https://github.com/dotnet/runtime/issues/92487 + // The fix has been incorporated into .NET 9 (and the polyfilled implementations in netfx). + // Should be removed once .NET 8 support is dropped. + if (GetGenericParameterDefinition(parameterInfo) is { ParameterType: { IsGenericParameter: true } typeParam }) + { + // Step 1. Look for nullable annotations on the type parameter. + if (GetNullableFlags(typeParam) is byte[] flags) + { + return TranslateByte(flags[0]); + } + + // Step 2. Look for nullable annotations on the generic method declaration. + if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) + { + return TranslateByte(flag); + } + + // Step 3. Look for nullable annotations on the generic method declaration. + if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) + { + return TranslateByte(flag2); + } + + // Default to nullable. + return NullabilityState.Nullable; + + static byte[]? GetNullableFlags(MemberInfo member) + { + foreach (CustomAttributeData attr in member.GetCustomAttributesData()) + { + Type attrType = attr.AttributeType; + if (attrType.Name == "NullableAttribute" && attrType.Namespace == "System.Runtime.CompilerServices") + { + foreach (CustomAttributeTypedArgument ctorArg in attr.ConstructorArguments) + { + switch (ctorArg.Value) + { + case byte flag: + return [flag]; + case byte[] flags: + return flags; + } + } + } + } + + return null; + } + + static byte? GetNullableContextFlag(MemberInfo member) + { + foreach (CustomAttributeData attr in member.GetCustomAttributesData()) + { + Type attrType = attr.AttributeType; + if (attrType.Name == "NullableContextAttribute" && attrType.Namespace == "System.Runtime.CompilerServices") + { + foreach (CustomAttributeTypedArgument ctorArg in attr.ConstructorArguments) + { + if (ctorArg.Value is byte flag) + { + return flag; + } + } + } + } + + return null; + } + +#pragma warning disable S109 // Magic numbers should not be used + static NullabilityState TranslateByte(byte b) => b switch + { + 1 => NullabilityState.NotNull, + 2 => NullabilityState.Nullable, + _ => NullabilityState.Unknown + }; +#pragma warning restore S109 // Magic numbers should not be used + } + + static ParameterInfo GetGenericParameterDefinition(ParameterInfo parameter) + { + if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } + or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) + { + var genericMethod = (MethodBase)GetGenericMemberDefinition(parameter.Member); + return genericMethod.GetParameters()[parameter.Position]; + } + + return parameter; + } + + static MemberInfo GetGenericMemberDefinition(MemberInfo member) + { + if (member is Type type) + { + return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; + } + + if (member.DeclaringType?.IsConstructedGenericType is true) + { + return member.DeclaringType.GetGenericTypeDefinition().GetMemberWithSameMetadataDefinitionAs(member); + } + + if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) + { + return method.GetGenericMethodDefinition(); + } + + return member; + } +#endif + return context.Create(parameterInfo).WriteState; + } + + // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 + public static object? GetNormalizedDefaultValue(ParameterInfo parameterInfo) + { + Type parameterType = parameterInfo.ParameterType; + object? defaultValue = parameterInfo.DefaultValue; + + if (defaultValue is null) + { + return null; + } + + // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. + if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) + { + return null; + } + + // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly + // cf. https://github.com/dotnet/runtime/issues/68647 + if (parameterType.IsEnum) + { + return Enum.ToObject(parameterType, defaultValue); + } + + if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) + { + return Enum.ToObject(underlyingType, defaultValue); + } + + return defaultValue; + } + + // Resolves the deserialization constructor for a type using logic copied from + // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 + private static bool TryGetDeserializationConstructor( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] + Type type, + bool useDefaultCtorInAnnotatedStructs, + out ConstructorInfo? deserializationCtor) + { + ConstructorInfo? ctorWithAttribute = null; + ConstructorInfo? publicParameterlessCtor = null; + ConstructorInfo? lonePublicCtor = null; + + ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + if (constructors.Length == 1) + { + lonePublicCtor = constructors[0]; + } + + foreach (ConstructorInfo constructor in constructors) + { + if (HasJsonConstructorAttribute(constructor)) + { + if (ctorWithAttribute != null) + { + deserializationCtor = null; + return false; + } + + ctorWithAttribute = constructor; + } + else if (constructor.GetParameters().Length == 0) + { + publicParameterlessCtor = constructor; + } + } + + // Search for non-public ctors with [JsonConstructor]. + foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (HasJsonConstructorAttribute(constructor)) + { + if (ctorWithAttribute != null) + { + deserializationCtor = null; + return false; + } + + ctorWithAttribute = constructor; + } + } + + // Structs will use default constructor if attribute isn't used. + if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) + { + deserializationCtor = null; + return true; + } + + deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; + return true; + + static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => + constructorInfo.GetCustomAttribute() != null; + } + + // Parameter to property matching semantics as declared in + // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 + private readonly struct ParameterLookupKey : IEquatable + { + public ParameterLookupKey(string name, Type type) + { + Name = name; + Type = type; + } + + public string Name { get; } + public Type Type { get; } + + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); + public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); + public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); + } + } + +#if !NET + private static MemberInfo GetMemberWithSameMetadataDefinitionAs(this Type specializedType, MemberInfo member) + { + const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + return specializedType.GetMember(member.Name, member.MemberType, All).First(m => m.MetadataToken == member.MetadataToken); + } +#endif +} +#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs index 9c4b83f8343..5c6ce6d9ab7 100644 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs @@ -16,14 +16,9 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.Shared.Diagnostics; -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable LA0002 // Use 'Microsoft.Shared.Text.NumericExtensions.ToInvariantString' for improved performance #pragma warning disable S107 // Methods should not have too many parameters -#pragma warning disable S103 // Lines should not be too long #pragma warning disable S1121 // Assignments should not be made from within sub-expressions -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S3358 // Ternary operators should not be nested -#pragma warning disable EA0004 // Make type internal since project is executable namespace System.Text.Json.Schema; @@ -121,7 +116,7 @@ private static JsonSchema MapJsonSchemaCore( JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter; JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Options.NumberHandling; - if (!IsBuiltInConverter(effectiveConverter)) + if (!ReflectionHelpers.IsBuiltInConverter(effectiveConverter)) { // Return a `true` schema for types with user-defined converters. return CompleteSchema(ref state, JsonSchema.True); @@ -263,7 +258,8 @@ private static JsonSchema MapJsonSchemaCore( } } - Func? parameterInfoMapper = ResolveJsonConstructorParameterMapper(typeInfo); + Func? parameterInfoMapper = + ReflectionHelpers.ResolveJsonConstructorParameterMapper(typeInfo.Type, typeInfo); state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); foreach (JsonPropertyInfo property in typeInfo.Properties) @@ -277,13 +273,13 @@ private static JsonSchema MapJsonSchemaCore( JsonTypeInfo propertyTypeInfo = typeInfo.Options.GetTypeInfo(property.PropertyType); // Resolve the attribute provider for the property. - ICustomAttributeProvider? attributeProvider = ResolveAttributeProvider(typeInfo.Type, property); + ICustomAttributeProvider? attributeProvider = ReflectionHelpers.ResolveAttributeProvider(typeInfo.Type, property); // Declare the property as nullable if either getter or setter are nullable. bool isNonNullableProperty = false; if (attributeProvider is MemberInfo memberInfo) { - NullabilityInfo nullabilityInfo = state.NullabilityInfoContext.GetMemberNullability(memberInfo); + NullabilityInfo nullabilityInfo = ReflectionHelpers.GetMemberNullability(state.NullabilityInfoContext, memberInfo); isNonNullableProperty = (property.Get is null || nullabilityInfo.ReadState is NullabilityState.NotNull) && (property.Set is null || nullabilityInfo.WriteState is NullabilityState.NotNull); @@ -347,7 +343,7 @@ private static JsonSchema MapJsonSchemaCore( }); case JsonTypeInfoKind.Enumerable: - Type elementType = GetElementType(typeInfo); + Type elementType = ReflectionHelpers.GetElementType(typeInfo); JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementType); if (typeDiscriminator is null) @@ -398,7 +394,7 @@ private static JsonSchema MapJsonSchemaCore( } case JsonTypeInfoKind.Dictionary: - Type valueType = GetElementType(typeInfo); + Type valueType = ReflectionHelpers.GetElementType(typeInfo); JsonTypeInfo valueTypeInfo = typeInfo.Options.GetTypeInfo(valueType); List>? dictProps = null; @@ -449,17 +445,28 @@ JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) { if (schema.Ref is null) { - // 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. - bool isNullableSchema = (propertyInfo != null || parameterInfo != null) - ? !isNonNullableType - : CanBeNull(typeInfo.Type) && !parentPolymorphicTypeIsNonNullable && !state.ExporterOptions.TreatNullObliviousAsNonNullable; - - if (isNullableSchema) + if (IsNullableSchema(ref state)) { schema.MakeNullable(); } + + bool IsNullableSchema(ref GenerationState state) + { + // A schema is marked as nullable if either + // 1. We have a schema for a property where either the getter or setter are marked as nullable. + // 2. We have a schema for a reference type, unless we're explicitly treating null-oblivious types as non-nullable + + if (propertyInfo != null || parameterInfo != null) + { + return !isNonNullableType; + } + else + { + return ReflectionHelpers.CanBeNull(typeInfo.Type) && + !parentPolymorphicTypeIsNonNullable && + !state.ExporterOptions.TreatNullObliviousAsNonNullable; + } + } } if (state.ExporterOptions.TransformSchemaNode != null) @@ -636,11 +643,18 @@ private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, Jso if ((numberHandling & (JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)) != 0) { - pattern = schemaType is JsonSchemaType.Integer - ? @"^-?(?:0|[1-9]\d*)$" - : isIeeeFloatingPoint - ? @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$" - : @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; + if (schemaType is JsonSchemaType.Integer) + { + pattern = @"^-?(?:0|[1-9]\d*)$"; + } + else if (isIeeeFloatingPoint) + { + pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$"; + } + else + { + pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; + } schemaType |= JsonSchemaType.String; } @@ -660,62 +674,16 @@ private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, Jso return new JsonSchema { Type = schemaType, Pattern = pattern }; } - // Uses reflection to determine the element type of an enumerable or dictionary type - // Workaround for https://github.com/dotnet/runtime/issues/77306#issuecomment-2007887560 - private static Type GetElementType(JsonTypeInfo typeInfo) - { - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary, "TypeInfo must be of collection type"); - _elementTypeProperty ??= typeof(JsonTypeInfo).GetProperty("ElementType", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - return (Type)_elementTypeProperty?.GetValue(typeInfo)!; - } - - private static PropertyInfo? _elementTypeProperty; - - // The .NET 8 source generator doesn't populate attribute providers for properties - // cf. https://github.com/dotnet/runtime/issues/100095 - // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property - // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static ICustomAttributeProvider? ResolveAttributeProvider(Type? declaringType, JsonPropertyInfo? propertyInfo) - { - if (declaringType is null || propertyInfo is null) - { - return null; - } - - if (propertyInfo.AttributeProvider is { } provider) - { - return provider; - } - - _memberNameProperty ??= typeof(JsonPropertyInfo).GetProperty("MemberName", BindingFlags.Instance | BindingFlags.NonPublic)!; - var memberName = (string?)_memberNameProperty.GetValue(propertyInfo); - if (memberName is not null) - { - return declaringType.GetMember(memberName, MemberTypes.Property | MemberTypes.Field, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).FirstOrDefault(); - } - - return null; - } - - private static PropertyInfo? _memberNameProperty; - - // Uses reflection to determine any custom converters specified for the element of a nullable type. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] private static JsonConverter? ExtractCustomNullableConverter(JsonConverter? converter) { - Debug.Assert(converter is null || IsBuiltInConverter(converter), "If specified the converter must be built-in."); + Debug.Assert(converter is null || ReflectionHelpers.IsBuiltInConverter(converter), "If specified the converter must be built-in."); - // There is unfortunately no way in which we can obtain the element converter from a nullable converter without resorting to private reflection - // https://github.com/dotnet/runtime/blob/release/8.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/NullableConverter.cs#L15-L17 - Type? converterType = converter?.GetType(); - if (converterType?.Name == "NullableConverter`1") + if (converter is null) { - FieldInfo elementConverterField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_elementConverter"); - return (JsonConverter)elementConverterField!.GetValue(converter)!; + return null; } - return null; + return ReflectionHelpers.GetElementConverter(converter); } private static void ValidateOptions(JsonSerializerOptions options) @@ -740,12 +708,12 @@ private static void ResolveParameterInfo( Debug.Assert(parameterTypeInfo.Type == parameter.ParameterType, "The typeInfo type must match the ParameterInfo type."); // Incorporate the nullability information from the parameter. - isNonNullable = nullabilityInfoContext.GetParameterNullability(parameter) is NullabilityState.NotNull; + isNonNullable = ReflectionHelpers.GetParameterNullability(nullabilityInfoContext, parameter) is NullabilityState.NotNull; if (parameter.HasDefaultValue) { // Append the default value to the description. - object? defaultVal = parameter.GetNormalizedDefaultValue(); + object? defaultVal = ReflectionHelpers.GetNormalizedDefaultValue(parameter); defaultValue = JsonSerializer.SerializeToNode(defaultVal, parameterTypeInfo); hasDefaultValue = true; } @@ -758,25 +726,19 @@ private static void ResolveParameterInfo( } } - // Uses reflection to determine schema for enum types // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs#L498-L521 - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConverter converter) { - Debug.Assert(typeInfo.Type.IsEnum && IsBuiltInConverter(converter), "must be using a built-in enum converter."); + Debug.Assert(typeInfo.Type.IsEnum && ReflectionHelpers.IsBuiltInConverter(converter), "must be using a built-in enum converter."); if (converter is JsonConverterFactory factory) { converter = factory.CreateConverter(typeInfo.Type, typeInfo.Options)!; } - Type converterType = converter.GetType(); - FieldInfo converterOptionsField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_converterOptions"); - FieldInfo namingPolicyField = converterType.GetPrivateFieldWithPotentiallyTrimmedMetadata("_namingPolicy"); + ReflectionHelpers.GetEnumConverterConfig(converter, out JsonNamingPolicy? namingPolicy, out bool allowString); - const int EnumConverterOptionsAllowStrings = 1; - var converterOptions = (int)converterOptionsField!.GetValue(converter)!; - if ((converterOptions & EnumConverterOptionsAllowStrings) != 0) + if (allowString) { // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity. @@ -787,7 +749,6 @@ private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConv return new() { Type = JsonSchemaType.String }; } - var namingPolicy = (JsonNamingPolicy?)namingPolicyField!.GetValue(converter)!; JsonArray enumValues = new(); foreach (string name in Enum.GetNames(typeInfo.Type)) { @@ -803,290 +764,6 @@ private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConv return new() { Type = JsonSchemaType.Integer }; } - private static NullabilityState GetParameterNullability(this NullabilityInfoContext context, ParameterInfo parameterInfo) - { -#if !NET9_0_OR_GREATER - // Workaround for https://github.com/dotnet/runtime/issues/92487 - if (GetGenericParameterDefinition(parameterInfo) is { ParameterType: { IsGenericParameter: true } typeParam }) - { - // Step 1. Look for nullable annotations on the type parameter. - if (GetNullableFlags(typeParam) is byte[] flags) - { - return TranslateByte(flags[0]); - } - - // Step 2. Look for nullable annotations on the generic method declaration. - if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) - { - return TranslateByte(flag); - } - - // Step 3. Look for nullable annotations on the generic method declaration. - if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) - { - return TranslateByte(flag2); - } - - // Default to nullable. - return NullabilityState.Nullable; - -#if NETCOREAPP - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", - Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] -#endif - static byte[]? GetNullableFlags(MemberInfo member) - { - Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => - { - Type attrType = attr.GetType(); - return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableAttribute"; - }); - - return (byte[])attr?.GetType().GetField("NullableFlags")?.GetValue(attr)!; - } - - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", - Justification = "We're resolving private fields of the built-in enum converter which cannot have been trimmed away.")] - static byte? GetNullableContextFlag(MemberInfo member) - { - Attribute? attr = member.GetCustomAttributes().FirstOrDefault(attr => - { - Type attrType = attr.GetType(); - return attrType.Namespace == "System.Runtime.CompilerServices" && attrType.Name == "NullableContextAttribute"; - }); - - return (byte?)attr?.GetType().GetField("Flag")?.GetValue(attr)!; - } - -#pragma warning disable S109 // Magic numbers should not be used - static NullabilityState TranslateByte(byte b) => b switch - { - 1 => NullabilityState.NotNull, - 2 => NullabilityState.Nullable, - _ => NullabilityState.Unknown - }; -#pragma warning restore S109 // Magic numbers should not be used - } - - static ParameterInfo GetGenericParameterDefinition(ParameterInfo parameter) - { - if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } - or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) - { - var genericMethod = (MethodBase)GetGenericMemberDefinition(parameter.Member); - return genericMethod.GetParameters()[parameter.Position]; - } - - return parameter; - } - - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", - Justification = "Looking up the generic member definition of the provided member.")] - static MemberInfo GetGenericMemberDefinition(MemberInfo member) - { - if (member is Type type) - { - return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; - } - - if (member.DeclaringType!.IsConstructedGenericType) - { - const BindingFlags AllMemberFlags = - BindingFlags.Static | BindingFlags.Instance | - BindingFlags.Public | BindingFlags.NonPublic; - - return member.DeclaringType.GetGenericTypeDefinition() - .GetMember(member.Name, AllMemberFlags) - .First(m => m.MetadataToken == member.MetadataToken); - } - - if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) - { - return method.GetGenericMethodDefinition(); - } - - return member; - } -#endif - return context.Create(parameterInfo).WriteState; - } - - // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 - private static object? GetNormalizedDefaultValue(this ParameterInfo parameterInfo) - { - Type parameterType = parameterInfo.ParameterType; - object? defaultValue = parameterInfo.DefaultValue; - - if (defaultValue is null) - { - return null; - } - - // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. - if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) - { - return null; - } - - // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly - // cf. https://github.com/dotnet/runtime/issues/68647 - if (parameterType.IsEnum) - { - return Enum.ToObject(parameterType, defaultValue); - } - - if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) - { - return Enum.ToObject(underlyingType, defaultValue); - } - - return defaultValue; - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static FieldInfo GetPrivateFieldWithPotentiallyTrimmedMetadata(this Type type, string fieldName) - { - FieldInfo? field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); - if (field is null) - { - throw new InvalidOperationException( - $"Could not resolve metadata for field '{fieldName}' in type '{type}'. " + - "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."); - } - - return field; - } - - // Resolves the parameters of the deserialization constructor for a type, if they exist. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static Func? ResolveJsonConstructorParameterMapper(JsonTypeInfo typeInfo) - { - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object, "Should only be passed object JSON kinds."); - - if (typeInfo.Properties.Count > 0 && - typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used - typeInfo.Type.TryGetDeserializationConstructor(useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) - { - ParameterInfo[]? parameters = ctor?.GetParameters(); - if (parameters?.Length > 0) - { - Dictionary dict = new(parameters.Length); - foreach (ParameterInfo parameter in parameters) - { - if (parameter.Name is not null) - { - // We don't care about null parameter names or conflicts since they - // would have already been rejected by JsonTypeInfo exporterOptions. - dict[new(parameter.Name, parameter.ParameterType)] = parameter; - } - } - - return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; - } - } - - return null; - } - - // Parameter to property matching semantics as declared in - // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 - private readonly struct ParameterLookupKey : IEquatable - { - public ParameterLookupKey(string name, Type type) - { - Name = name; - Type = type; - } - - public string Name { get; } - public Type Type { get; } - - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); - public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); - public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); - } - - // Resolves the deserialization constructor for a type using logic copied from - // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static bool TryGetDeserializationConstructor( - this Type type, - bool useDefaultCtorInAnnotatedStructs, - out ConstructorInfo? deserializationCtor) - { - ConstructorInfo? ctorWithAttribute = null; - ConstructorInfo? publicParameterlessCtor = null; - ConstructorInfo? lonePublicCtor = null; - - ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); - - if (constructors.Length == 1) - { - lonePublicCtor = constructors[0]; - } - - foreach (ConstructorInfo constructor in constructors) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - else if (constructor.GetParameters().Length == 0) - { - publicParameterlessCtor = constructor; - } - } - - // Search for non-public ctors with [JsonConstructor]. - foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - } - - // Structs will use default constructor if attribute isn't used. - if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) - { - deserializationCtor = null; - return true; - } - - deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; - return true; - - static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => - constructorInfo.GetCustomAttribute() != null; - } - - private static bool IsBuiltInConverter(JsonConverter converter) => - converter.GetType().Assembly == typeof(JsonConverter).Assembly; - - // Resolves the nullable reference type annotations for a property or field, - // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. - private static NullabilityInfo GetMemberNullability(this NullabilityInfoContext context, MemberInfo memberInfo) - { - Debug.Assert(memberInfo is PropertyInfo or FieldInfo, "Member must be property or field."); - return memberInfo is PropertyInfo prop - ? context.Create(prop) - : context.Create((FieldInfo)memberInfo); - } - - private static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; - private static class JsonSchemaConstants { public const string SchemaPropertyName = "$schema"; @@ -1116,10 +793,6 @@ private static class ThrowHelpers public static void ThrowInvalidOperationException_MaxDepthReached() => throw new InvalidOperationException("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting."); - [DoesNotReturn] - public static void ThrowInvalidOperationException_TrimmedMethodParameters(MethodBase method) => - throw new InvalidOperationException($"The parameters for method '{method}' have been trimmed away."); - [DoesNotReturn] public static void ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported() => throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); diff --git a/src/Shared/Shared.csproj b/src/Shared/Shared.csproj index 58ec4eda535..439c3788557 100644 --- a/src/Shared/Shared.csproj +++ b/src/Shared/Shared.csproj @@ -17,6 +17,7 @@ true true true + true diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs index 93207a7167f..2ec81987dc2 100644 --- a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs @@ -15,7 +15,6 @@ using Xunit; #pragma warning disable SA1402 // File may only contain a single type -#pragma warning disable xUnit1000 // Test classes must be public namespace Microsoft.Extensions.AI.JsonSchemaExporter; diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index f8c54fdb178..d21a40640dd 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -27,7 +27,6 @@ #pragma warning disable CA1052 // Static holder types should be Static or NotInheritable #pragma warning disable S1121 // Assignments should not be made from within sub-expressions #pragma warning disable IDE0073 // The file header is missing or not located at the top of the file -#pragma warning disable SA1402 // File may only contain a single type namespace Microsoft.Extensions.AI.JsonSchemaExporter; @@ -511,7 +510,6 @@ of the type which points to the first occurrence. */ } """); -#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/107545 gets backported // Global JsonUnmappedMemberHandling.Disallow setting yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -530,7 +528,6 @@ of the type which points to the first occurrence. */ } """, Options: new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }); -#endif yield return new TestData( Value: new() { MaybeNull = null!, AllowNull = null, NotNull = null, DisallowNull = null!, NotNullDisallowNull = "str" }, From 32505677dcff4657b0d49ba1e8ea4bd0f363cbec Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 1 Nov 2024 09:38:21 -0400 Subject: [PATCH 091/190] Improve AdditionalPropertiesDictionary (#5593) - Add a strongly-typed Enumerator - Add a TryAdd method - Add a DebuggerDisplay for Count - Add a DebuggerTypeProxy for the collection of properties --- .../AdditionalPropertiesDictionary.cs | 92 ++++++++++++++++++- .../AdditionalPropertiesDictionaryTests.cs | 41 +++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs index 616ad284198..4a681d4679a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs @@ -4,13 +4,21 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S1144 // Unused private types or members should be removed +#pragma warning disable S2365 // Properties should not make collection or array copies +#pragma warning disable S3604 // Member initializer values should not be redundant namespace Microsoft.Extensions.AI; /// Provides a dictionary used as the AdditionalProperties dictionary on Microsoft.Extensions.AI objects. +[DebuggerTypeProxy(typeof(DebugView))] +[DebuggerDisplay("Count = {Count}")] public sealed class AdditionalPropertiesDictionary : IDictionary, IReadOnlyDictionary { /// The underlying dictionary. @@ -77,6 +85,25 @@ public object? this[string key] /// public void Add(string key, object? value) => _dictionary.Add(key, value); + /// Attempts to add the specified key and value to the dictionary. + /// The key of the element to add. + /// The value of the element to add. + /// if the key/value pair was added to the dictionary successfully; otherwise, . + public bool TryAdd(string key, object? value) + { +#if NET + return _dictionary.TryAdd(key, value); +#else + if (!_dictionary.ContainsKey(key)) + { + _dictionary.Add(key, value); + return true; + } + + return false; +#endif + } + /// void ICollection>.Add(KeyValuePair item) => ((ICollection>)_dictionary).Add(item); @@ -93,11 +120,17 @@ public object? this[string key] void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) => ((ICollection>)_dictionary).CopyTo(array, arrayIndex); + /// + /// Returns an enumerator that iterates through the . + /// + /// An that enumerates the contents of the . + public Enumerator GetEnumerator() => new(_dictionary.GetEnumerator()); + /// - public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); + IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); /// - IEnumerator IEnumerable.GetEnumerator() => _dictionary.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// public bool Remove(string key) => _dictionary.Remove(key); @@ -156,4 +189,59 @@ public bool TryGetValue(string key, [NotNullWhen(true)] out T? value) value = default; return false; } + + /// Enumerates the elements of an . + public struct Enumerator : IEnumerator> + { + /// The wrapped dictionary enumerator. + private Dictionary.Enumerator _dictionaryEnumerator; + + /// Initializes a new instance of the struct with the dictionary enumerator to wrap. + /// The dictionary enumerator to wrap. + internal Enumerator(Dictionary.Enumerator dictionaryEnumerator) + { + _dictionaryEnumerator = dictionaryEnumerator; + } + + /// + public KeyValuePair Current => _dictionaryEnumerator.Current; + + /// + object IEnumerator.Current => Current; + + /// + public void Dispose() => _dictionaryEnumerator.Dispose(); + + /// + public bool MoveNext() => _dictionaryEnumerator.MoveNext(); + + /// + public void Reset() => Reset(ref _dictionaryEnumerator); + + /// Calls on an enumerator. + private static void Reset(ref TEnumerator enumerator) + where TEnumerator : struct, IEnumerator + { + enumerator.Reset(); + } + } + + /// Provides a debugger view for the collection. + private sealed class DebugView(AdditionalPropertiesDictionary properties) + { + private readonly AdditionalPropertiesDictionary _properties = Throw.IfNull(properties); + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public AdditionalProperty[] Items => (from p in _properties select new AdditionalProperty(p.Key, p.Value)).ToArray(); + + [DebuggerDisplay("{Value}", Name = "[{Key}]")] + public readonly struct AdditionalProperty(string key, object? value) + { + [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] + public string Key { get; } = key; + + [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] + public object? Value { get; } = value; + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs index a9a544c8ca8..09f515fa066 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs @@ -90,4 +90,45 @@ static void AssertNotFound(T1 input) Assert.Equal(default(T2), value); } } + + [Fact] + public void TryAdd_AddsOnlyIfNonExistent() + { + AdditionalPropertiesDictionary d = []; + + Assert.False(d.ContainsKey("key")); + Assert.True(d.TryAdd("key", "value")); + Assert.True(d.ContainsKey("key")); + Assert.Equal("value", d["key"]); + + Assert.False(d.TryAdd("key", "value2")); + Assert.True(d.ContainsKey("key")); + Assert.Equal("value", d["key"]); + } + + [Fact] + public void Enumerator_EnumeratesAllItems() + { + AdditionalPropertiesDictionary d = []; + + const int NumProperties = 10; + for (int i = 0; i < NumProperties; i++) + { + d.Add($"key{i}", $"value{i}"); + } + + Assert.Equal(NumProperties, d.Count); + + // This depends on an implementation detail of the ordering in which the dictionary + // enumerates items. If that ever changes, this test will need to be updated. + int count = 0; + foreach (KeyValuePair item in d) + { + Assert.Equal($"key{count}", item.Key); + Assert.Equal($"value{count}", item.Value); + count++; + } + + Assert.Equal(NumProperties, count); + } } From 228a96dcdaa28a009f6205d20b08fcea81c09d22 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 1 Nov 2024 10:19:52 -0400 Subject: [PATCH 092/190] Add UseEmbeddingGenerationOptions (#5594) * Add UseEmbeddingGenerationOptions Counterpart to UseChatOptions * Document/test null options returned from callback --- .../ConfigureOptionsChatClient.cs | 9 ++- ...igureOptionsChatClientBuilderExtensions.cs | 9 ++- .../ConfigureOptionsEmbeddingGenerator.cs | 75 +++++++++++++++++++ ...ionsEmbeddingGeneratorBuilderExtensions.cs | 56 ++++++++++++++ .../ConfigureOptionsChatClientTests.cs | 8 +- ...ConfigureOptionsEmbeddingGeneratorTests.cs | 58 ++++++++++++++ 6 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs index 895bf8873df..990c92d3ad9 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs @@ -17,7 +17,7 @@ namespace Microsoft.Extensions.AI; /// /// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options /// with a new instance, the callback may simply return that new instance, for example _ => new ChatOptions() { MaxTokens = 1000 }. To provide -/// a new instance only if the caller-supplied instance is `null`, the callback may conditionally return a new instance, for example +/// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example /// options => options ?? new ChatOptions() { MaxTokens = 1000 }. Any changes to the caller-provided options instance will persist on the /// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance /// and mutating the clone, for example: @@ -31,6 +31,9 @@ namespace Microsoft.Extensions.AI; /// /// /// +/// The callback may return , in which case a options will be passed to the next client in the pipeline. +/// +/// /// The provided implementation of is thread-safe for concurrent use so long as the employed configuration /// callback is also thread-safe for concurrent requests. If callers employ a shared options instance, care should be taken in the /// configuration callback, as multiple calls to it may end up running in parallel with the same options instance. @@ -39,7 +42,7 @@ namespace Microsoft.Extensions.AI; public sealed class ConfigureOptionsChatClient : DelegatingChatClient { /// The callback delegate used to configure options. - private readonly Func _configureOptions; + private readonly Func _configureOptions; /// Initializes a new instance of the class with the specified callback. /// The inner client. @@ -47,7 +50,7 @@ public sealed class ConfigureOptionsChatClient : DelegatingChatClient /// The delegate to invoke to configure the instance. It is passed the caller-supplied /// instance and should return the configured instance to use. /// - public ConfigureOptionsChatClient(IChatClient innerClient, Func configureOptions) + public ConfigureOptionsChatClient(IChatClient innerClient, Func configureOptions) : base(innerClient) { _configureOptions = Throw.IfNull(configureOptions); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs index 12b903c0dac..2d98fbd9003 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs @@ -21,9 +21,10 @@ public static class ConfigureOptionsChatClientBuilderExtensions /// /// The . /// + /// /// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options /// with a new instance, the callback may simply return that new instance, for example _ => new ChatOptions() { MaxTokens = 1000 }. To provide - /// a new instance only if the caller-supplied instance is `null`, the callback may conditionally return a new instance, for example + /// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example /// options => options ?? new ChatOptions() { MaxTokens = 1000 }. Any changes to the caller-provided options instance will persist on the /// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance /// and mutating the clone, for example: @@ -35,9 +36,13 @@ public static class ConfigureOptionsChatClientBuilderExtensions /// return newOptions; /// } /// + /// + /// + /// The callback may return , in which case a options will be passed to the next client in the pipeline. + /// /// public static ChatClientBuilder UseChatOptions( - this ChatClientBuilder builder, Func configureOptions) + this ChatClientBuilder builder, Func configureOptions) { _ = Throw.IfNull(builder); _ = Throw.IfNull(configureOptions); diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs new file mode 100644 index 00000000000..9068ac41caa --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable SA1629 // Documentation text should end with a period + +namespace Microsoft.Extensions.AI; + +/// A delegating embedding generator that updates or replaces the used by the remainder of the pipeline. +/// Specifies the type of the input passed to the generator. +/// Specifies the type of the embedding instance produced by the generator. +/// +/// +/// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options +/// with a new instance, the callback may simply return that new instance, for example _ => new EmbeddingGenerationOptions() { Dimensions = 100 }. To provide +/// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example +/// options => options ?? new EmbeddingGenerationOptions() { Dimensions = 100 }. Any changes to the caller-provided options instance will persist on the +/// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance +/// and mutating the clone, for example: +/// +/// options => +/// { +/// var newOptions = options?.Clone() ?? new(); +/// newOptions.Dimensions = 100; +/// return newOptions; +/// } +/// +/// +/// +/// The callback may return , in which case a options will be passed to the next generator in the pipeline. +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the employed configuration +/// callback is also thread-safe for concurrent requests. If callers employ a shared options instance, care should be taken in the +/// configuration callback, as multiple calls to it may end up running in parallel with the same options instance. +/// +/// +public sealed class ConfigureOptionsEmbeddingGenerator : DelegatingEmbeddingGenerator + where TEmbedding : Embedding +{ + /// The callback delegate used to configure options. + private readonly Func _configureOptions; + + /// + /// Initializes a new instance of the class with the + /// specified callback. + /// + /// The inner generator. + /// + /// The delegate to invoke to configure the instance. It is passed the caller-supplied + /// instance and should return the configured instance to use. + /// + public ConfigureOptionsEmbeddingGenerator( + IEmbeddingGenerator innerGenerator, + Func configureOptions) + : base(innerGenerator) + { + _configureOptions = Throw.IfNull(configureOptions); + } + + /// + public override async Task> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + return await base.GenerateAsync(values, _configureOptions(options), cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..011f4c058e9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable SA1629 // Documentation text should end with a period + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +public static class ConfigureOptionsEmbeddingGeneratorBuilderExtensions +{ + /// + /// Adds a callback that updates or replaces . This can be used to set default options. + /// + /// Specifies the type of the input passed to the generator. + /// Specifies the type of the embedding instance produced by the generator. + /// The . + /// + /// The delegate to invoke to configure the instance. It is passed the caller-supplied + /// instance and should return the configured instance to use. + /// + /// The . + /// + /// + /// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options + /// with a new instance, the callback may simply return that new instance, for example _ => new EmbeddingGenerationOptions() { Dimensions = 100 }. To provide + /// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example + /// options => options ?? new EmbeddingGenerationOptions() { Dimensions = 100 }. Any changes to the caller-provided options instance will persist on the + /// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance + /// and mutating the clone, for example: + /// + /// options => + /// { + /// var newOptions = options?.Clone() ?? new(); + /// newOptions.Dimensions = 100; + /// return newOptions; + /// } + /// + /// + /// + /// The callback may return , in which case a options will be passed to the next generator in the pipeline. + /// + /// + public static EmbeddingGeneratorBuilder UseEmbeddingGenerationOptions( + this EmbeddingGeneratorBuilder builder, + Func configureOptions) + where TEmbedding : Embedding + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(configureOptions); + + return builder.Use(innerGenerator => new ConfigureOptionsEmbeddingGenerator(innerGenerator, configureOptions)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs index a27761c99ec..a911340813f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs @@ -26,11 +26,13 @@ public void UseChatOptions_InvalidArgs_Throws() Assert.Throws("configureOptions", () => builder.UseChatOptions(null!)); } - [Fact] - public async Task ConfigureOptions_ReturnedInstancePassedToNextClient() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullReturned) { ChatOptions providedOptions = new(); - ChatOptions returnedOptions = new(); + ChatOptions? returnedOptions = nullReturned ? null : new(); ChatCompletion expectedCompletion = new(Array.Empty()); var expectedUpdates = Enumerable.Range(0, 3).Select(i => new StreamingChatCompletionUpdate()).ToArray(); using CancellationTokenSource cts = new(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs new file mode 100644 index 00000000000..b8a4b82cb59 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ConfigureOptionsEmbeddingGeneratorTests +{ + [Fact] + public void ConfigureOptionsEmbeddingGenerator_InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new ConfigureOptionsEmbeddingGenerator>(null!, _ => new EmbeddingGenerationOptions())); + Assert.Throws("configureOptions", () => new ConfigureOptionsEmbeddingGenerator>(new TestEmbeddingGenerator(), null!)); + } + + [Fact] + public void UseEmbeddingGenerationOptions_InvalidArgs_Throws() + { + var builder = new EmbeddingGeneratorBuilder>(); + Assert.Throws("configureOptions", () => builder.UseEmbeddingGenerationOptions(null!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullReturned) + { + EmbeddingGenerationOptions providedOptions = new(); + EmbeddingGenerationOptions? returnedOptions = nullReturned ? null : new(); + GeneratedEmbeddings> expectedEmbeddings = []; + using CancellationTokenSource cts = new(); + + using IEmbeddingGenerator> innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = (inputs, options, cancellationToken) => + { + Assert.Same(returnedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return Task.FromResult(expectedEmbeddings); + } + }; + + using var generator = new EmbeddingGeneratorBuilder>() + .UseEmbeddingGenerationOptions(options => + { + Assert.Same(providedOptions, options); + return returnedOptions; + }) + .Use(innerGenerator); + + var embeddings = await generator.GenerateAsync([], providedOptions, cts.Token); + Assert.Same(expectedEmbeddings, embeddings); + } +} From 80c926338022979ba4df5645fa76f1aa16bc6a1e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 4 Nov 2024 11:45:20 +0000 Subject: [PATCH 093/190] HybridCache stability and logging improvements (#5467) * - handle serialization failures - enforce payload quota - enforce key validity - add proper logging (infrastructure failure: needs attn) # Conflicts: # src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj * - add "callback" to .dic - log deserialization failures - expose serialization failures - tests for serialization logging scenarios * support and tests for stability despite unreliable L2 * nit * Compile for NS2.0 * include enabled check in our log output * add event-source tracing and counters * explicitly specify event-source guid * satisfy the stylebot overloads * nix SDT * fix failing CI test * limit to net462 * PR feedback (all except event tests) * naming * add event source tests * fix redundant comment * add clarification * more clarifications * dance for our robot overlords * drop Microsoft.Extensions.Telemetry.Abstractions package-ref * fix glitchy L2 test * better tracking for invalid event-source state * reserve non-printable characters from keys, to prevent L2 abuse * improve test output for ETW * tyop * ETW tests: allow longer if needed * whitespace * more ETW fixins --------- Co-authored-by: Jose Perez Rodriguez --- eng/packages/TestOnly.props | 3 +- eng/spellchecking_exclusions.dic | Bin 176 -> 198 bytes .../Internal/DefaultHybridCache.CacheItem.cs | 16 +- .../DefaultHybridCache.ImmutableCacheItem.cs | 3 +- .../Internal/DefaultHybridCache.L2.cs | 12 +- .../DefaultHybridCache.MutableCacheItem.cs | 21 +- .../DefaultHybridCache.Serialization.cs | 52 ++- .../DefaultHybridCache.StampedeState.cs | 2 - .../DefaultHybridCache.StampedeStateT.cs | 177 ++++++++--- .../Internal/DefaultHybridCache.cs | 81 ++++- .../Internal/HybridCacheEventSource.cs | 203 ++++++++++++ .../Internal/InbuiltTypeSerializer.cs | 20 +- .../Internal/Log.cs | 49 +++ .../Internal/RecyclableArrayBufferWriter.cs | 17 +- ...Microsoft.Extensions.Caching.Hybrid.csproj | 7 +- ....Extensions.Compliance.Abstractions.csproj | 1 + .../HybridCacheEventSourceTests.cs | 205 ++++++++++++ .../LogCollector.cs | 84 +++++ ...oft.Extensions.Caching.Hybrid.Tests.csproj | 4 +- .../NullDistributedCache.cs | 31 ++ .../SizeTests.cs | 298 ++++++++++++++++-- .../TestEventListener.cs | 189 +++++++++++ .../UnreliableL2Tests.cs | 251 +++++++++++++++ ...nsions.Telemetry.Abstractions.Tests.csproj | 4 + 24 files changed, 1632 insertions(+), 98 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs create mode 100644 src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/Log.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LogCollector.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/NullDistributedCache.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/UnreliableL2Tests.cs diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index f6753c9c14d..4c78b8dcbe8 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -7,6 +7,7 @@ + @@ -20,7 +21,7 @@ - + diff --git a/eng/spellchecking_exclusions.dic b/eng/spellchecking_exclusions.dic index 2fc9b74699b3a4f15d47904fb03678d52114bd26..7259681651670edef6d5aad2d32ac8843ddc50fe 100644 GIT binary patch delta 29 icmdnMc#Ltv2C@JDk{J>ia)2-iNGCI7Gw?ESF#rIa=?D-2 delta 6 NcmX@cxPfuP1^@{{0=WPH diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs index 5585b9b2a29..05edc65dc06 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Threading; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -22,7 +23,7 @@ internal abstract class CacheItem // zero. // This counter also drives cache lifetime, with the cache itself incrementing the count by one. In the // case of mutable data, cache eviction may reduce this to zero (in cooperation with any concurrent readers, - // who incr/decr around their fetch), allowing safe buffer recycling. + // who increment/decrement around their fetch), allowing safe buffer recycling. internal int RefCount => Volatile.Read(ref _refCount); @@ -89,13 +90,18 @@ internal abstract class CacheItem : CacheItem { public abstract bool TryGetSize(out long size); - // attempt to get a value that was *not* previously reserved - public abstract bool TryGetValue(out T value); + // Attempt to get a value that was *not* previously reserved. + // Note on ILogger usage: we don't want to propagate and store this everywhere. + // It is used for reporting deserialization problems - pass it as needed. + // (CacheItem gets into the IMemoryCache - let's minimize the onward reachable set + // of that cache, by only handing it leaf nodes of a "tree", not a "graph" with + // backwards access - we can also limit object size at the same time) + public abstract bool TryGetValue(ILogger log, out T value); // get a value that *was* reserved, countermanding our reservation in the process - public T GetReservedValue() + public T GetReservedValue(ILogger log) { - if (!TryGetValue(out var value)) + if (!TryGetValue(log, out var value)) { Throw(); } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs index 9ae8468ba29..2e803d87ad6 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Threading; +using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -38,7 +39,7 @@ public void SetValue(T value, long size) Size = size; } - public override bool TryGetValue(out T value) + public override bool TryGetValue(ILogger log, out T value) { value = _value; return true; // always available diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs index 1e694448737..230a657bdc3 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs @@ -16,12 +16,16 @@ internal partial class DefaultHybridCache { [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Manual sync check")] [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Manual sync check")] + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Explicit async exception handling")] + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Deliberate recycle only on success")] internal ValueTask GetFromL2Async(string key, CancellationToken token) { switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) { case CacheFeatures.BackendCache: // legacy byte[]-based + var pendingLegacy = _backendCache!.GetAsync(key, token); + #if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER if (!pendingLegacy.IsCompletedSuccessfully) #else @@ -36,6 +40,7 @@ internal ValueTask GetFromL2Async(string key, CancellationToken tok case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // IBufferWriter-based RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); var cache = Unsafe.As(_backendCache!); // type-checked already + var pendingBuffers = cache.TryGetAsync(key, writer, token); if (!pendingBuffers.IsCompletedSuccessfully) { @@ -49,7 +54,7 @@ internal ValueTask GetFromL2Async(string key, CancellationToken tok return new(result); } - return default; + return default; // treat as a "miss" static async Task AwaitedLegacyAsync(Task pending, DefaultHybridCache @this) { @@ -115,6 +120,11 @@ internal void SetL1(string key, CacheItem value, HybridCacheEntryOptions? // commit cacheEntry.Dispose(); + + if (HybridCacheEventSource.Log.IsEnabled()) + { + HybridCacheEventSource.Log.LocalCacheWrite(); + } } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs index 2d02c23b6d8..db95e8c4590 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs @@ -1,14 +1,18 @@ // 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.Logging; + namespace Microsoft.Extensions.Caching.Hybrid.Internal; internal partial class DefaultHybridCache { private sealed partial class MutableCacheItem : CacheItem // used to hold types that require defensive copies { - private IHybridCacheSerializer _serializer = null!; // deferred until SetValue + private IHybridCacheSerializer? _serializer; private BufferChunk _buffer; + private T? _fallbackValue; // only used in the case of serialization failures public override bool NeedsEvictionCallback => _buffer.ReturnToPool; @@ -21,16 +25,27 @@ public void SetValue(ref BufferChunk buffer, IHybridCacheSerializer serialize buffer = default; // we're taking over the lifetime; the caller no longer has it! } - public override bool TryGetValue(out T value) + public void SetFallbackValue(T fallbackValue) + { + _fallbackValue = fallbackValue; + } + + public override bool TryGetValue(ILogger log, out T value) { // only if we haven't already burned if (TryReserve()) { try { - value = _serializer.Deserialize(_buffer.AsSequence()); + var serializer = _serializer; + value = serializer is null ? _fallbackValue! : serializer.Deserialize(_buffer.AsSequence()); return true; } + catch (Exception ex) + { + log.DeserializationFailure(ex); + throw; + } finally { _ = Release(); diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs index 523a95e279a..d12b2cce592 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Concurrent; -using System.Reflection; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; @@ -51,4 +51,54 @@ static IHybridCacheSerializer ResolveAndAddSerializer(DefaultHybridCache @thi return serializer; } } + + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Intentional for logged failure mode")] + private bool TrySerialize(T value, out BufferChunk buffer, out IHybridCacheSerializer? serializer) + { + // note: also returns the serializer we resolved, because most-any time we want to serialize, we'll also want + // to make sure we use that same instance later (without needing to re-resolve and/or store the entire HC machinery) + + RecyclableArrayBufferWriter? writer = null; + buffer = default; + try + { + writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); // note this lifetime spans the SetL2Async + serializer = GetSerializer(); + + serializer.Serialize(value, writer); + + buffer = new(writer.DetachCommitted(out var length), length, returnToPool: true); // remove buffer ownership from the writer + writer.Dispose(); // we're done with the writer + return true; + } + catch (Exception ex) + { + bool knownCause = false; + + // ^^^ if we know what happened, we can record directly via cause-specific events + // and treat as a handled failure (i.e. return false) - otherwise, we'll bubble + // the fault up a few layers *in addition to* logging in a failure event + + if (writer is not null) + { + if (writer.QuotaExceeded) + { + _logger.MaximumPayloadBytesExceeded(ex, MaximumPayloadBytes); + knownCause = true; + } + + writer.Dispose(); + } + + if (!knownCause) + { + _logger.SerializationFailure(ex); + throw; + } + + buffer = default; + serializer = null; + return false; + } + } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeState.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeState.cs index eba71774395..e2439357f26 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeState.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeState.cs @@ -74,8 +74,6 @@ protected StampedeState(DefaultHybridCache cache, in StampedeKey key, CacheItem public abstract void Execute(); - protected int MaximumPayloadBytes => _cache.MaximumPayloadBytes; - public override string ToString() => Key.ToString(); public abstract void SetCanceled(); diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs index 4e45acae930..4be5b351485 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using static Microsoft.Extensions.Caching.Hybrid.Internal.DefaultHybridCache; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -14,7 +15,8 @@ internal partial class DefaultHybridCache { internal sealed class StampedeState : StampedeState { - private const HybridCacheEntryFlags FlagsDisableL1AndL2 = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite; + // note on terminology: L1 and L2 are, for brevity, used interchangeably with "local" and "distributed" cache, i.e. `IMemoryCache` and `IDistributedCache` + private const HybridCacheEntryFlags FlagsDisableL1AndL2Write = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite; private readonly TaskCompletionSource>? _result; private TState? _state; @@ -76,13 +78,13 @@ public Task ExecuteDirectAsync(in TState state, Func _result?.TrySetCanceled(SharedToken); [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Custom task management")] - public ValueTask JoinAsync(CancellationToken token) + public ValueTask JoinAsync(ILogger log, CancellationToken token) { // If the underlying has already completed, and/or our local token can't cancel: we // can simply wrap the shared task; otherwise, we need our own cancellation state. - return token.CanBeCanceled && !Task.IsCompleted ? WithCancellationAsync(this, token) : UnwrapReservedAsync(); + return token.CanBeCanceled && !Task.IsCompleted ? WithCancellationAsync(log, this, token) : UnwrapReservedAsync(log); - static async ValueTask WithCancellationAsync(StampedeState stampede, CancellationToken token) + static async ValueTask WithCancellationAsync(ILogger log, StampedeState stampede, CancellationToken token) { var cancelStub = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var reg = token.Register(static obj => @@ -112,7 +114,7 @@ static async ValueTask WithCancellationAsync(StampedeState stamped } // outside the catch, so we know we only decrement one way or the other - return result.GetReservedValue(); + return result.GetReservedValue(log); } } @@ -133,7 +135,7 @@ static Task> InvalidAsync() => System.Threading.Tasks.Task.FromExce [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Checked manual unwrap")] [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Checked manual unwrap")] [SuppressMessage("Major Code Smell", "S1121:Assignments should not be made from within sub-expressions", Justification = "Unusual, but legit here")] - internal ValueTask UnwrapReservedAsync() + internal ValueTask UnwrapReservedAsync(ILogger log) { var task = Task; #if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER @@ -142,16 +144,16 @@ internal ValueTask UnwrapReservedAsync() if (task.Status == TaskStatus.RanToCompletion) #endif { - return new(task.Result.GetReservedValue()); + return new(task.Result.GetReservedValue(log)); } // if the type is immutable, callers can share the final step too (this may leave dangling // reservation counters, but that's OK) - var result = ImmutableTypeCache.IsImmutable ? (_sharedUnwrap ??= AwaitedAsync(Task)) : AwaitedAsync(Task); + var result = ImmutableTypeCache.IsImmutable ? (_sharedUnwrap ??= AwaitedAsync(log, Task)) : AwaitedAsync(log, Task); return new(result); - static async Task AwaitedAsync(Task> task) - => (await task.ConfigureAwait(false)).GetReservedValue(); + static async Task AwaitedAsync(ILogger log, Task> task) + => (await task.ConfigureAwait(false)).GetReservedValue(log); } [DoesNotReturn] @@ -161,12 +163,43 @@ static async Task AwaitedAsync(Task> task) [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Exception is passed through to faulted task result")] private async Task BackgroundFetchAsync() { + bool eventSourceEnabled = HybridCacheEventSource.Log.IsEnabled(); try { // read from L2 if appropriate if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheRead) == 0) { - var result = await Cache.GetFromL2Async(Key.Key, SharedToken).ConfigureAwait(false); + BufferChunk result; + try + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.DistributedCacheGet(); + } + + result = await Cache.GetFromL2Async(Key.Key, SharedToken).ConfigureAwait(false); + if (eventSourceEnabled) + { + if (result.Array is not null) + { + HybridCacheEventSource.Log.DistributedCacheHit(); + } + else + { + HybridCacheEventSource.Log.DistributedCacheMiss(); + } + } + } + catch (Exception ex) + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.DistributedCacheFailed(); + } + + Cache._logger.CacheUnderlyingDataQueryFailure(ex); + result = default; // treat as "miss" + } if (result.Array is not null) { @@ -179,7 +212,30 @@ private async Task BackgroundFetchAsync() if ((Key.Flags & HybridCacheEntryFlags.DisableUnderlyingData) == 0) { // invoke the callback supplied by the caller - T newValue = await _underlying!(_state!, SharedToken).ConfigureAwait(false); + T newValue; + try + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.UnderlyingDataQueryStart(); + } + + newValue = await _underlying!(_state!, SharedToken).ConfigureAwait(false); + + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.UnderlyingDataQueryComplete(); + } + } + catch + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.UnderlyingDataQueryFailed(); + } + + throw; + } // If we're writing this value *anywhere*, we're going to need to serialize; this is obvious // in the case of L2, but we also need it for L1, because MemoryCache might be enforcing @@ -187,11 +243,11 @@ private async Task BackgroundFetchAsync() // Likewise, if we're writing to a MutableCacheItem, we'll be serializing *anyway* for the payload. // // Rephrasing that: the only scenario in which we *do not* need to serialize is if: - // - it is an ImmutableCacheItem - // - we're writing neither to L1 nor L2 + // - it is an ImmutableCacheItem (so we don't need bytes for the CacheItem, L1) + // - we're not writing to L2 CacheItem cacheItem = CacheItem; - bool skipSerialize = cacheItem is ImmutableCacheItem && (Key.Flags & FlagsDisableL1AndL2) == FlagsDisableL1AndL2; + bool skipSerialize = cacheItem is ImmutableCacheItem && (Key.Flags & FlagsDisableL1AndL2Write) == FlagsDisableL1AndL2Write; if (skipSerialize) { @@ -202,33 +258,55 @@ private async Task BackgroundFetchAsync() // ^^^ The first thing we need to do is make sure we're not getting into a thread race over buffer disposal. // In particular, if this cache item is somehow so short-lived that the buffers would be released *before* we're // done writing them to L2, which happens *after* we've provided the value to consumers. - RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); // note this lifetime spans the SetL2Async - IHybridCacheSerializer serializer = Cache.GetSerializer(); - serializer.Serialize(newValue, writer); - BufferChunk buffer = new(writer.DetachCommitted(out var length), length, returnToPool: true); // remove buffer ownership from the writer - writer.Dispose(); // we're done with the writer - - // protect "buffer" (this is why we "reserved") for writing to L2 if needed; SetResultPreSerialized - // *may* (depending on context) claim this buffer, in which case "bufferToRelease" gets reset, and - // the final RecycleIfAppropriate() is a no-op; however, the buffer is valid in either event, - // (with TryReserve above guaranteeing that we aren't in a race condition). - BufferChunk bufferToRelease = buffer; - - // and since "bufferToRelease" is the thing that will be returned at some point, we can make it explicit - // that we do not need or want "buffer" to do any recycling (they're the same memory) - buffer = buffer.DoNotReturnToPool(); - - // set the underlying result for this operation (includes L1 write if appropriate) - SetResultPreSerialized(newValue, ref bufferToRelease, serializer); - - // Note that at this point we've already released most or all of the waiting callers. Everything - // from this point onwards happens in the background, from the perspective of the calling code. - - // Write to L2 if appropriate. - if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0) + + BufferChunk bufferToRelease = default; + if (Cache.TrySerialize(newValue, out var buffer, out var serializer)) { - // We already have the payload serialized, so this is trivial to do. - await Cache.SetL2Async(Key.Key, in buffer, _options, SharedToken).ConfigureAwait(false); + // note we also capture the resolved serializer ^^^ - we'll need it again later + + // protect "buffer" (this is why we "reserved") for writing to L2 if needed; SetResultPreSerialized + // *may* (depending on context) claim this buffer, in which case "bufferToRelease" gets reset, and + // the final RecycleIfAppropriate() is a no-op; however, the buffer is valid in either event, + // (with TryReserve above guaranteeing that we aren't in a race condition). + bufferToRelease = buffer; + + // and since "bufferToRelease" is the thing that will be returned at some point, we can make it explicit + // that we do not need or want "buffer" to do any recycling (they're the same memory) + buffer = buffer.DoNotReturnToPool(); + + // set the underlying result for this operation (includes L1 write if appropriate) + SetResultPreSerialized(newValue, ref bufferToRelease, serializer); + + // Note that at this point we've already released most or all of the waiting callers. Everything + // from this point onwards happens in the background, from the perspective of the calling code. + + // Write to L2 if appropriate. + if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0) + { + // We already have the payload serialized, so this is trivial to do. + try + { + await Cache.SetL2Async(Key.Key, in buffer, _options, SharedToken).ConfigureAwait(false); + + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.DistributedCacheWrite(); + } + } + catch (Exception ex) + { + // log the L2 write failure, but that doesn't need to interrupt the app flow (so: + // don't rethrow); L1 will still reduce impact, and L1 without L2 is better than + // hard failure every time + Cache._logger.CacheBackendWriteFailure(ex); + } + } + } + else + { + // unable to serialize (or quota exceeded); try to at least store the onwards value; this is + // especially useful for immutable data types + SetResultPreSerialized(newValue, ref bufferToRelease, serializer); } // Release our hook on the CacheItem (only really important for "mutable"). @@ -309,7 +387,7 @@ private void SetResultAndRecycleIfAppropriate(ref BufferChunk value) private void SetImmutableResultWithoutSerialize(T value) { - Debug.Assert((Key.Flags & FlagsDisableL1AndL2) == FlagsDisableL1AndL2, "Only expected if L1+L2 disabled"); + Debug.Assert((Key.Flags & FlagsDisableL1AndL2Write) == FlagsDisableL1AndL2Write, "Only expected if L1+L2 disabled"); // set a result from a value we calculated directly CacheItem cacheItem; @@ -328,7 +406,7 @@ private void SetImmutableResultWithoutSerialize(T value) SetResult(cacheItem); } - private void SetResultPreSerialized(T value, ref BufferChunk buffer, IHybridCacheSerializer serializer) + private void SetResultPreSerialized(T value, ref BufferChunk buffer, IHybridCacheSerializer? serializer) { // set a result from a value we calculated directly that // has ALREADY BEEN SERIALIZED (we can optionally consume this buffer) @@ -343,8 +421,17 @@ private void SetResultPreSerialized(T value, ref BufferChunk buffer, IHybridCach // (but leave the buffer alone) break; case MutableCacheItem mutable: - mutable.SetValue(ref buffer, serializer); - mutable.DebugOnlyTrackBuffer(Cache); + if (serializer is null) + { + // serialization is failing; set fallback value + mutable.SetFallbackValue(value); + } + else + { + mutable.SetValue(ref buffer, serializer); + mutable.DebugOnlyTrackBuffer(Cache); + } + cacheItem = mutable; break; default: diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs index c789e7c6652..71dbf71fd54 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs @@ -22,6 +22,9 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; /// internal sealed partial class DefaultHybridCache : HybridCache { + // reserve non-printable characters from keys, to prevent potential L2 abuse + private static readonly char[] _keyReservedCharacters = Enumerable.Range(0, 32).Select(i => (char)i).ToArray(); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0032:Use auto property", Justification = "Keep usage explicit")] private readonly IDistributedCache? _backendCache; [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0032:Use auto property", Justification = "Keep usage explicit")] @@ -37,6 +40,7 @@ internal sealed partial class DefaultHybridCache : HybridCache private readonly HybridCacheEntryFlags _defaultFlags; // note this already includes hardFlags private readonly TimeSpan _defaultExpiration; private readonly TimeSpan _defaultLocalCacheExpiration; + private readonly int _maximumKeyLength; private readonly DistributedCacheEntryOptions _defaultDistributedCacheExpiration; @@ -90,6 +94,7 @@ public DefaultHybridCache(IOptions options, IServiceProvider _serializerFactories = factories; MaximumPayloadBytes = checked((int)_options.MaximumPayloadBytes); // for now hard-limit to 2GiB + _maximumKeyLength = _options.MaximumKeyLength; var defaultEntryOptions = _options.DefaultEntryOptions; @@ -119,11 +124,33 @@ public override ValueTask GetOrCreateAsync(string key, TState stat } var flags = GetEffectiveFlags(options); - if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0 && _localCache.TryGetValue(key, out var untyped) - && untyped is CacheItem typed && typed.TryGetValue(out var value)) + if (!ValidateKey(key)) { - // short-circuit - return new(value); + // we can't use cache, but we can still provide the data + return RunWithoutCacheAsync(flags, state, underlyingDataCallback, cancellationToken); + } + + bool eventSourceEnabled = HybridCacheEventSource.Log.IsEnabled(); + if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0) + { + if (_localCache.TryGetValue(key, out var untyped) + && untyped is CacheItem typed && typed.TryGetValue(_logger, out var value)) + { + // short-circuit + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.LocalCacheHit(); + } + + return new(value); + } + else + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.LocalCacheMiss(); + } + } } if (GetOrCreateStampedeState(key, flags, out var stampede, canBeCanceled)) @@ -139,11 +166,19 @@ public override ValueTask GetOrCreateAsync(string key, TState stat { // we're going to run to completion; no need to get complicated _ = stampede.ExecuteDirectAsync(in state, underlyingDataCallback, options); // this larger task includes L2 write etc - return stampede.UnwrapReservedAsync(); + return stampede.UnwrapReservedAsync(_logger); + } + } + else + { + // pre-existing query + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.StampedeJoin(); } } - return stampede.JoinAsync(cancellationToken); + return stampede.JoinAsync(_logger, cancellationToken); } public override ValueTask RemoveAsync(string key, CancellationToken token = default) @@ -164,7 +199,39 @@ public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptio return new(state.ExecuteDirectAsync(value, static (state, _) => new(state), options)); // note this spans L2 write etc } + private static ValueTask RunWithoutCacheAsync(HybridCacheEntryFlags flags, TState state, + Func> underlyingDataCallback, + CancellationToken cancellationToken) + { + return (flags & HybridCacheEntryFlags.DisableUnderlyingData) == 0 + ? underlyingDataCallback(state, cancellationToken) : default; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private HybridCacheEntryFlags GetEffectiveFlags(HybridCacheEntryOptions? options) - => (options?.Flags | _hardFlags) ?? _defaultFlags; + => (options?.Flags | _hardFlags) ?? _defaultFlags; + + private bool ValidateKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + _logger.KeyEmptyOrWhitespace(); + return false; + } + + if (key.Length > _maximumKeyLength) + { + _logger.MaximumKeyLengthExceeded(_maximumKeyLength, key.Length); + return false; + } + + if (key.IndexOfAny(_keyReservedCharacters) >= 0) + { + _logger.KeyInvalidContent(); + return false; + } + + // nothing to complain about + return true; + } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs new file mode 100644 index 00000000000..92a5d729e57 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs @@ -0,0 +1,203 @@ +// 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.Diagnostics.Tracing; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +[EventSource(Name = "Microsoft-Extensions-HybridCache")] +internal sealed class HybridCacheEventSource : EventSource +{ + public static readonly HybridCacheEventSource Log = new(); + + internal const int EventIdLocalCacheHit = 1; + internal const int EventIdLocalCacheMiss = 2; + internal const int EventIdDistributedCacheGet = 3; + internal const int EventIdDistributedCacheHit = 4; + internal const int EventIdDistributedCacheMiss = 5; + internal const int EventIdDistributedCacheFailed = 6; + internal const int EventIdUnderlyingDataQueryStart = 7; + internal const int EventIdUnderlyingDataQueryComplete = 8; + internal const int EventIdUnderlyingDataQueryFailed = 9; + internal const int EventIdLocalCacheWrite = 10; + internal const int EventIdDistributedCacheWrite = 11; + internal const int EventIdStampedeJoin = 12; + + // fast local counters + private long _totalLocalCacheHit; + private long _totalLocalCacheMiss; + private long _totalDistributedCacheHit; + private long _totalDistributedCacheMiss; + private long _totalUnderlyingDataQuery; + private long _currentUnderlyingDataQuery; + private long _currentDistributedFetch; + private long _totalLocalCacheWrite; + private long _totalDistributedCacheWrite; + private long _totalStampedeJoin; + +#if !(NETSTANDARD2_0 || NET462) + // full Counter infrastructure + private DiagnosticCounter[]? _counters; +#endif + + [NonEvent] + public void ResetCounters() + { + Debug.WriteLine($"{nameof(HybridCacheEventSource)} counters reset!"); + + Volatile.Write(ref _totalLocalCacheHit, 0); + Volatile.Write(ref _totalLocalCacheMiss, 0); + Volatile.Write(ref _totalDistributedCacheHit, 0); + Volatile.Write(ref _totalDistributedCacheMiss, 0); + Volatile.Write(ref _totalUnderlyingDataQuery, 0); + Volatile.Write(ref _currentUnderlyingDataQuery, 0); + Volatile.Write(ref _currentDistributedFetch, 0); + Volatile.Write(ref _totalLocalCacheWrite, 0); + Volatile.Write(ref _totalDistributedCacheWrite, 0); + Volatile.Write(ref _totalStampedeJoin, 0); + } + + [Event(EventIdLocalCacheHit, Level = EventLevel.Verbose)] + public void LocalCacheHit() + { + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalLocalCacheHit); + WriteEvent(EventIdLocalCacheHit); + } + + [Event(EventIdLocalCacheMiss, Level = EventLevel.Verbose)] + public void LocalCacheMiss() + { + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalLocalCacheMiss); + WriteEvent(EventIdLocalCacheMiss); + } + + [Event(EventIdDistributedCacheGet, Level = EventLevel.Verbose)] + public void DistributedCacheGet() + { + // should be followed by DistributedCacheHit, DistributedCacheMiss or DistributedCacheFailed + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _currentDistributedFetch); + WriteEvent(EventIdDistributedCacheGet); + } + + [Event(EventIdDistributedCacheHit, Level = EventLevel.Verbose)] + public void DistributedCacheHit() + { + DebugAssertEnabled(); + + // note: not concerned about off-by-one here, i.e. don't panic + // about these two being atomic ref each-other - just the overall shape + _ = Interlocked.Increment(ref _totalDistributedCacheHit); + _ = Interlocked.Decrement(ref _currentDistributedFetch); + WriteEvent(EventIdDistributedCacheHit); + } + + [Event(EventIdDistributedCacheMiss, Level = EventLevel.Verbose)] + public void DistributedCacheMiss() + { + DebugAssertEnabled(); + + // note: not concerned about off-by-one here, i.e. don't panic + // about these two being atomic ref each-other - just the overall shape + _ = Interlocked.Increment(ref _totalDistributedCacheMiss); + _ = Interlocked.Decrement(ref _currentDistributedFetch); + WriteEvent(EventIdDistributedCacheMiss); + } + + [Event(EventIdDistributedCacheFailed, Level = EventLevel.Error)] + public void DistributedCacheFailed() + { + DebugAssertEnabled(); + _ = Interlocked.Decrement(ref _currentDistributedFetch); + WriteEvent(EventIdDistributedCacheFailed); + } + + [Event(EventIdUnderlyingDataQueryStart, Level = EventLevel.Verbose)] + public void UnderlyingDataQueryStart() + { + // should be followed by UnderlyingDataQueryComplete or UnderlyingDataQueryFailed + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalUnderlyingDataQuery); + _ = Interlocked.Increment(ref _currentUnderlyingDataQuery); + WriteEvent(EventIdUnderlyingDataQueryStart); + } + + [Event(EventIdUnderlyingDataQueryComplete, Level = EventLevel.Verbose)] + public void UnderlyingDataQueryComplete() + { + DebugAssertEnabled(); + _ = Interlocked.Decrement(ref _currentUnderlyingDataQuery); + WriteEvent(EventIdUnderlyingDataQueryComplete); + } + + [Event(EventIdUnderlyingDataQueryFailed, Level = EventLevel.Error)] + public void UnderlyingDataQueryFailed() + { + DebugAssertEnabled(); + _ = Interlocked.Decrement(ref _currentUnderlyingDataQuery); + WriteEvent(EventIdUnderlyingDataQueryFailed); + } + + [Event(EventIdLocalCacheWrite, Level = EventLevel.Verbose)] + public void LocalCacheWrite() + { + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalLocalCacheWrite); + WriteEvent(EventIdLocalCacheWrite); + } + + [Event(EventIdDistributedCacheWrite, Level = EventLevel.Verbose)] + public void DistributedCacheWrite() + { + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalDistributedCacheWrite); + WriteEvent(EventIdDistributedCacheWrite); + } + + [Event(EventIdStampedeJoin, Level = EventLevel.Verbose)] + internal void StampedeJoin() + { + DebugAssertEnabled(); + _ = Interlocked.Increment(ref _totalStampedeJoin); + WriteEvent(EventIdStampedeJoin); + } + +#if !(NETSTANDARD2_0 || NET462) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Lifetime exceeds obvious scope; handed to event source")] + [NonEvent] + protected override void OnEventCommand(EventCommandEventArgs command) + { + if (command.Command == EventCommand.Enable) + { + // lazily create counters on first Enable + _counters ??= [ + new PollingCounter("total-local-cache-hits", this, () => Volatile.Read(ref _totalLocalCacheHit)) { DisplayName = "Total Local Cache Hits" }, + new PollingCounter("total-local-cache-misses", this, () => Volatile.Read(ref _totalLocalCacheMiss)) { DisplayName = "Total Local Cache Misses" }, + new PollingCounter("total-distributed-cache-hits", this, () => Volatile.Read(ref _totalDistributedCacheHit)) { DisplayName = "Total Distributed Cache Hits" }, + new PollingCounter("total-distributed-cache-misses", this, () => Volatile.Read(ref _totalDistributedCacheMiss)) { DisplayName = "Total Distributed Cache Misses" }, + new PollingCounter("total-data-query", this, () => Volatile.Read(ref _totalUnderlyingDataQuery)) { DisplayName = "Total Data Queries" }, + new PollingCounter("current-data-query", this, () => Volatile.Read(ref _currentUnderlyingDataQuery)) { DisplayName = "Current Data Queries" }, + new PollingCounter("current-distributed-cache-fetches", this, () => Volatile.Read(ref _currentDistributedFetch)) { DisplayName = "Current Distributed Cache Fetches" }, + new PollingCounter("total-local-cache-writes", this, () => Volatile.Read(ref _totalLocalCacheWrite)) { DisplayName = "Total Local Cache Writes" }, + new PollingCounter("total-distributed-cache-writes", this, () => Volatile.Read(ref _totalDistributedCacheWrite)) { DisplayName = "Total Distributed Cache Writes" }, + new PollingCounter("total-stampede-joins", this, () => Volatile.Read(ref _totalStampedeJoin)) { DisplayName = "Total Stampede Joins" }, + ]; + } + + base.OnEventCommand(command); + } +#endif + + [NonEvent] + [Conditional("DEBUG")] + private void DebugAssertEnabled([CallerMemberName] string caller = "") + { + Debug.Assert(IsEnabled(), $"Missing check to {nameof(HybridCacheEventSource)}.{nameof(Log)}.{nameof(IsEnabled)} from {caller}"); + Debug.WriteLine($"{nameof(HybridCacheEventSource)}: {caller}"); // also log all event calls, for visibility + } +} diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/InbuiltTypeSerializer.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/InbuiltTypeSerializer.cs index 3ef26341433..4800428a88f 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/InbuiltTypeSerializer.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/InbuiltTypeSerializer.cs @@ -17,6 +17,18 @@ internal sealed class InbuiltTypeSerializer : IHybridCacheSerializer, IH public static InbuiltTypeSerializer Instance { get; } = new(); string IHybridCacheSerializer.Deserialize(ReadOnlySequence source) + => DeserializeString(source); + + void IHybridCacheSerializer.Serialize(string value, IBufferWriter target) + => SerializeString(value, target); + + byte[] IHybridCacheSerializer.Deserialize(ReadOnlySequence source) + => source.ToArray(); + + void IHybridCacheSerializer.Serialize(byte[] value, IBufferWriter target) + => target.Write(value); + + internal static string DeserializeString(ReadOnlySequence source) { #if NET5_0_OR_GREATER return Encoding.UTF8.GetString(source); @@ -36,7 +48,7 @@ string IHybridCacheSerializer.Deserialize(ReadOnlySequence source) #endif } - void IHybridCacheSerializer.Serialize(string value, IBufferWriter target) + internal static void SerializeString(string value, IBufferWriter target) { #if NET5_0_OR_GREATER Encoding.UTF8.GetBytes(value, target); @@ -49,10 +61,4 @@ void IHybridCacheSerializer.Serialize(string value, IBufferWriter ArrayPool.Shared.Return(oversized); #endif } - - byte[] IHybridCacheSerializer.Deserialize(ReadOnlySequence source) - => source.ToArray(); - - void IHybridCacheSerializer.Serialize(byte[] value, IBufferWriter target) - => target.Write(value); } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/Log.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/Log.cs new file mode 100644 index 00000000000..785107c32ec --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/Log.cs @@ -0,0 +1,49 @@ +// 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.Logging; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +internal static partial class Log +{ + internal const int IdMaximumPayloadBytesExceeded = 1; + internal const int IdSerializationFailure = 2; + internal const int IdDeserializationFailure = 3; + internal const int IdKeyEmptyOrWhitespace = 4; + internal const int IdMaximumKeyLengthExceeded = 5; + internal const int IdCacheBackendReadFailure = 6; + internal const int IdCacheBackendWriteFailure = 7; + internal const int IdKeyInvalidContent = 8; + + [LoggerMessage(LogLevel.Error, "Cache MaximumPayloadBytes ({Bytes}) exceeded.", EventName = "MaximumPayloadBytesExceeded", EventId = IdMaximumPayloadBytesExceeded, SkipEnabledCheck = false)] + internal static partial void MaximumPayloadBytesExceeded(this ILogger logger, Exception e, int bytes); + + // note that serialization is critical enough that we perform hard failures in addition to logging; serialization + // failures are unlikely to be transient (i.e. connectivity); we would rather this shows up in QA, rather than + // being invisible and people *thinking* they're using cache, when actually they are not + + [LoggerMessage(LogLevel.Error, "Cache serialization failure.", EventName = "SerializationFailure", EventId = IdSerializationFailure, SkipEnabledCheck = false)] + internal static partial void SerializationFailure(this ILogger logger, Exception e); + + // (see same notes per SerializationFailure) + [LoggerMessage(LogLevel.Error, "Cache deserialization failure.", EventName = "DeserializationFailure", EventId = IdDeserializationFailure, SkipEnabledCheck = false)] + internal static partial void DeserializationFailure(this ILogger logger, Exception e); + + [LoggerMessage(LogLevel.Error, "Cache key empty or whitespace.", EventName = "KeyEmptyOrWhitespace", EventId = IdKeyEmptyOrWhitespace, SkipEnabledCheck = false)] + internal static partial void KeyEmptyOrWhitespace(this ILogger logger); + + [LoggerMessage(LogLevel.Error, "Cache key maximum length exceeded (maximum: {MaxLength}, actual: {KeyLength}).", EventName = "MaximumKeyLengthExceeded", + EventId = IdMaximumKeyLengthExceeded, SkipEnabledCheck = false)] + internal static partial void MaximumKeyLengthExceeded(this ILogger logger, int maxLength, int keyLength); + + [LoggerMessage(LogLevel.Error, "Cache backend read failure.", EventName = "CacheBackendReadFailure", EventId = IdCacheBackendReadFailure, SkipEnabledCheck = false)] + internal static partial void CacheUnderlyingDataQueryFailure(this ILogger logger, Exception ex); + + [LoggerMessage(LogLevel.Error, "Cache backend write failure.", EventName = "CacheBackendWriteFailure", EventId = IdCacheBackendWriteFailure, SkipEnabledCheck = false)] + internal static partial void CacheBackendWriteFailure(this ILogger logger, Exception ex); + + [LoggerMessage(LogLevel.Error, "Cache key contains invalid content.", EventName = "KeyInvalidContent", EventId = IdKeyInvalidContent, SkipEnabledCheck = false)] + internal static partial void KeyInvalidContent(this ILogger logger); // for PII etc reasons, we won't include the actual key +} diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs index 2f2da2c7019..985d55c9f0e 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs @@ -46,20 +46,20 @@ internal sealed class RecyclableArrayBufferWriter : IBufferWriter, IDispos public int CommittedBytes => _index; public int FreeCapacity => _buffer.Length - _index; + public bool QuotaExceeded { get; private set; } + private static RecyclableArrayBufferWriter? _spare; + public static RecyclableArrayBufferWriter Create(int maxLength) { var obj = Interlocked.Exchange(ref _spare, null) ?? new(); - Debug.Assert(obj._index == 0, "index should be zero initially"); - obj._maxLength = maxLength; + obj.Initialize(maxLength); return obj; } private RecyclableArrayBufferWriter() { _buffer = []; - _index = 0; - _maxLength = int.MaxValue; } public void Dispose() @@ -91,6 +91,7 @@ public void Advance(int count) if (_index + count > _maxLength) { + QuotaExceeded = true; ThrowQuota(); } @@ -199,4 +200,12 @@ private void CheckAndResizeBuffer(int sizeHint) static void ThrowOutOfMemoryException() => throw new InvalidOperationException("Unable to grow buffer as requested"); } + + private void Initialize(int maxLength) + { + // think .ctor, but with pooled object re-use + _index = 0; + _maxLength = maxLength; + QuotaExceeded = false; + } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj index 1c59ccc088a..dfa70cd121e 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj @@ -4,7 +4,7 @@ Multi-level caching implementation building on and extending IDistributedCache $(NetCoreTargetFrameworks)$(ConditionalNet462);netstandard2.0;netstandard2.1 true - cache;distributedcache;hybrid + cache;distributedcache;hybridcache true true true @@ -20,6 +20,11 @@ true + true + true + + + false diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj index 5a6c93e1dc7..c83b7284da5 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Compliance + $(NetCoreTargetFrameworks);netstandard2.0; Abstractions to help ensure compliant data management. Fundamentals diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs new file mode 100644 index 00000000000..3a266af7ce3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs @@ -0,0 +1,205 @@ +// 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.Tracing; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public class HybridCacheEventSourceTests(ITestOutputHelper log, TestEventListener listener) : IClassFixture +{ + // see notes in TestEventListener for context on fixture usage + + [SkippableFact] + public void MatchesNameAndGuid() + { + // Assert + Assert.Equal("Microsoft-Extensions-HybridCache", listener.Source.Name); + Assert.Equal(Guid.Parse("b3aca39e-5dc9-5e21-f669-b72225b66cfc"), listener.Source.Guid); // from name + } + + [SkippableFact] + public async Task LocalCacheHit() + { + AssertEnabled(); + + listener.Reset().Source.LocalCacheHit(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdLocalCacheHit, "LocalCacheHit", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-local-cache-hits", "Total Local Cache Hits", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task LocalCacheMiss() + { + AssertEnabled(); + + listener.Reset().Source.LocalCacheMiss(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdLocalCacheMiss, "LocalCacheMiss", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-local-cache-misses", "Total Local Cache Misses", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task DistributedCacheGet() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheGet(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheGet, "DistributedCacheGet", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("current-distributed-cache-fetches", "Current Distributed Cache Fetches", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task DistributedCacheHit() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheGet(); + listener.Reset(resetCounters: false).Source.DistributedCacheHit(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheHit, "DistributedCacheHit", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-distributed-cache-hits", "Total Distributed Cache Hits", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task DistributedCacheMiss() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheGet(); + listener.Reset(resetCounters: false).Source.DistributedCacheMiss(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheMiss, "DistributedCacheMiss", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-distributed-cache-misses", "Total Distributed Cache Misses", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task DistributedCacheFailed() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheGet(); + listener.Reset(resetCounters: false).Source.DistributedCacheFailed(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheFailed, "DistributedCacheFailed", EventLevel.Error); + + await AssertCountersAsync(); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task UnderlyingDataQueryStart() + { + AssertEnabled(); + + listener.Reset().Source.UnderlyingDataQueryStart(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdUnderlyingDataQueryStart, "UnderlyingDataQueryStart", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("current-data-query", "Current Data Queries", 1); + listener.AssertCounter("total-data-query", "Total Data Queries", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task UnderlyingDataQueryComplete() + { + AssertEnabled(); + + listener.Reset().Source.UnderlyingDataQueryStart(); + listener.Reset(resetCounters: false).Source.UnderlyingDataQueryComplete(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdUnderlyingDataQueryComplete, "UnderlyingDataQueryComplete", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-data-query", "Total Data Queries", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task UnderlyingDataQueryFailed() + { + AssertEnabled(); + + listener.Reset().Source.UnderlyingDataQueryStart(); + listener.Reset(resetCounters: false).Source.UnderlyingDataQueryFailed(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdUnderlyingDataQueryFailed, "UnderlyingDataQueryFailed", EventLevel.Error); + + await AssertCountersAsync(); + listener.AssertCounter("total-data-query", "Total Data Queries", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task LocalCacheWrite() + { + AssertEnabled(); + + listener.Reset().Source.LocalCacheWrite(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdLocalCacheWrite, "LocalCacheWrite", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-local-cache-writes", "Total Local Cache Writes", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task DistributedCacheWrite() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheWrite(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheWrite, "DistributedCacheWrite", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-distributed-cache-writes", "Total Distributed Cache Writes", 1); + listener.AssertRemainingCountersZero(); + } + + [SkippableFact] + public async Task StampedeJoin() + { + AssertEnabled(); + + listener.Reset().Source.StampedeJoin(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdStampedeJoin, "StampedeJoin", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-stampede-joins", "Total Stampede Joins", 1); + listener.AssertRemainingCountersZero(); + } + + private void AssertEnabled() + { + // including this data for visibility when tests fail - ETW subsystem can be ... weird + log.WriteLine($".NET {Environment.Version} on {Environment.OSVersion}, {IntPtr.Size * 8}-bit"); + + Skip.IfNot(listener.Source.IsEnabled(), "Event source not enabled"); + } + + private async Task AssertCountersAsync() + { + var count = await listener.TryAwaitCountersAsync(); + + // ETW counters timing can be painfully unpredictable; generally + // it'll work fine locally, especially on modern .NET, but: + // CI servers and netfx in particular - not so much. The tests + // can still observe and validate the simple events, though, which + // should be enough to be credible that the eventing system is + // fundamentally working. We're not meant to be testing that + // the counters system *itself* works! + + Skip.If(count == 0, "No counters received"); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LogCollector.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LogCollector.cs new file mode 100644 index 00000000000..bdb5ff981c0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LogCollector.cs @@ -0,0 +1,84 @@ +// 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; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +// dummy implementation for collecting test output +internal class LogCollector : ILoggerProvider +{ + private readonly List<(string categoryName, LogLevel logLevel, EventId eventId, Exception? exception, string message)> _items = []; + + public (string categoryName, LogLevel logLevel, EventId eventId, Exception? exception, string message)[] ToArray() + { + lock (_items) + { + return _items.ToArray(); + } + } + + public void WriteTo(ITestOutputHelper log) + { + lock (_items) + { + foreach (var logItem in _items) + { + var errSuffix = logItem.exception is null ? "" : $" - {logItem.exception.Message}"; + log.WriteLine($"{logItem.categoryName} {logItem.eventId}: {logItem.message}{errSuffix}"); + } + } + } + + public void AssertErrors(int[] errorIds) + { + lock (_items) + { + bool same; + if (errorIds.Length == _items.Count) + { + int index = 0; + same = true; + foreach (var item in _items) + { + if (item.eventId.Id != errorIds[index++]) + { + same = false; + break; + } + } + } + else + { + same = false; + } + + if (!same) + { + // we expect this to fail, then + Assert.Equal(string.Join(",", errorIds), string.Join(",", _items.Select(static x => x.eventId.Id))); + } + } + } + + ILogger ILoggerProvider.CreateLogger(string categoryName) => new TypedLogCollector(this, categoryName); + + void IDisposable.Dispose() + { + // nothing to do + } + + private sealed class TypedLogCollector(LogCollector parent, string categoryName) : ILogger + { + IDisposable? ILogger.BeginScope(TState state) => null; + bool ILogger.IsEnabled(LogLevel logLevel) => true; + void ILogger.Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (parent._items) + { + parent._items.Add((categoryName, logLevel, eventId, exception, formatter(state, exception))); + } + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj index ef80a84eee9..fb8863cf776 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -12,13 +12,15 @@ + - + + diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/NullDistributedCache.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/NullDistributedCache.cs new file mode 100644 index 00000000000..d07cb51bb93 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/NullDistributedCache.cs @@ -0,0 +1,31 @@ +// 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.Caching.Distributed; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +// dummy L2 that doesn't actually store anything +internal class NullDistributedCache : IDistributedCache +{ + byte[]? IDistributedCache.Get(string key) => null; + Task IDistributedCache.GetAsync(string key, CancellationToken token) => Task.FromResult(null); + void IDistributedCache.Refresh(string key) + { + // nothing to do + } + + Task IDistributedCache.RefreshAsync(string key, CancellationToken token) => Task.CompletedTask; + void IDistributedCache.Remove(string key) + { + // nothing to do + } + + Task IDistributedCache.RemoveAsync(string key, CancellationToken token) => Task.CompletedTask; + void IDistributedCache.Set(string key, byte[] value, DistributedCacheEntryOptions options) + { + // nothing to do + } + + Task IDistributedCache.SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token) => Task.CompletedTask; +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs index 119c2297882..66f4fc7628d 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs @@ -1,31 +1,60 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; +using System.ComponentModel; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; namespace Microsoft.Extensions.Caching.Hybrid.Tests; -public class SizeTests +public class SizeTests(ITestOutputHelper log) { [Theory] - [InlineData(null, true)] // does not enforce size limits - [InlineData(8L, false)] // unreasonably small limit; chosen because our test string has length 12 - hence no expectation to find the second time - [InlineData(1024L, true)] // reasonable size limit - public async Task ValidateSizeLimit_Immutable(long? sizeLimit, bool expectFromL1) + [InlineData("abc", null, true, null, null)] // does not enforce size limits + [InlineData("", null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData(" ", null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData(null, null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData("abc", 8L, false, null, null)] // unreasonably small limit; chosen because our test string has length 12 - hence no expectation to find the second time + [InlineData("abc", 1024L, true, null, null)] // reasonable size limit + [InlineData("abc", 1024L, true, 8L, null, Log.IdMaximumPayloadBytesExceeded)] // reasonable size limit, small HC quota + [InlineData("abc", null, false, null, 2, Log.IdMaximumKeyLengthExceeded, Log.IdMaximumKeyLengthExceeded)] // key limit exceeded + [InlineData("a\u0000c", null, false, null, null, Log.IdKeyInvalidContent, Log.IdKeyInvalidContent)] // invalid key + [InlineData("a\u001Fc", null, false, null, null, Log.IdKeyInvalidContent, Log.IdKeyInvalidContent)] // invalid key + [InlineData("a\u0020c", null, true, null, null)] // fine (this is just space) + public async Task ValidateSizeLimit_Immutable(string? key, long? sizeLimit, bool expectFromL1, long? maximumPayloadBytes, int? maximumKeyLength, + params int[] errorIds) { + using var collector = new LogCollector(); var services = new ServiceCollection(); services.AddMemoryCache(options => options.SizeLimit = sizeLimit); - services.AddHybridCache(); + services.AddHybridCache(options => + { + if (maximumKeyLength.HasValue) + { + options.MaximumKeyLength = maximumKeyLength.GetValueOrDefault(); + } + + if (maximumPayloadBytes.HasValue) + { + options.MaximumPayloadBytes = maximumPayloadBytes.GetValueOrDefault(); + } + }); + services.AddLogging(options => + { + options.ClearProviders(); + options.AddProvider(collector); + }); using ServiceProvider provider = services.BuildServiceProvider(); var cache = Assert.IsType(provider.GetRequiredService()); - const string Key = "abc"; - // this looks weird; it is intentionally not a const - we want to check // same instance without worrying about interning from raw literals string expected = new("simple value".ToArray()); - var actual = await cache.GetOrCreateAsync(Key, ct => new(expected)); + var actual = await cache.GetOrCreateAsync(key!, ct => new(expected)); // expect same contents Assert.Equal(expected, actual); @@ -35,7 +64,7 @@ public async Task ValidateSizeLimit_Immutable(long? sizeLimit, bool expectFromL1 Assert.Same(expected, actual); // rinse and repeat, to check we get the value from L1 - actual = await cache.GetOrCreateAsync(Key, ct => new(Guid.NewGuid().ToString())); + actual = await cache.GetOrCreateAsync(key!, ct => new(Guid.NewGuid().ToString())); if (expectFromL1) { @@ -51,30 +80,54 @@ public async Task ValidateSizeLimit_Immutable(long? sizeLimit, bool expectFromL1 // L1 cache not used Assert.NotEqual(expected, actual); } + + collector.WriteTo(log); + collector.AssertErrors(errorIds); } [Theory] - [InlineData(null, true)] // does not enforce size limits - [InlineData(8L, false)] // unreasonably small limit; chosen because our test string has length 12 - hence no expectation to find the second time - [InlineData(1024L, true)] // reasonable size limit - public async Task ValidateSizeLimit_Mutable(long? sizeLimit, bool expectFromL1) + [InlineData("abc", null, true, null, null)] // does not enforce size limits + [InlineData("", null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData(" ", null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData(null, null, false, null, null, Log.IdKeyEmptyOrWhitespace, Log.IdKeyEmptyOrWhitespace)] // invalid key + [InlineData("abc", 8L, false, null, null)] // unreasonably small limit; chosen because our test string has length 12 - hence no expectation to find the second time + [InlineData("abc", 1024L, true, null, null)] // reasonable size limit + [InlineData("abc", 1024L, true, 8L, null, Log.IdMaximumPayloadBytesExceeded)] // reasonable size limit, small HC quota + [InlineData("abc", null, false, null, 2, Log.IdMaximumKeyLengthExceeded, Log.IdMaximumKeyLengthExceeded)] // key limit exceeded + public async Task ValidateSizeLimit_Mutable(string? key, long? sizeLimit, bool expectFromL1, long? maximumPayloadBytes, int? maximumKeyLength, + params int[] errorIds) { + using var collector = new LogCollector(); var services = new ServiceCollection(); services.AddMemoryCache(options => options.SizeLimit = sizeLimit); - services.AddHybridCache(); + services.AddHybridCache(options => + { + if (maximumKeyLength.HasValue) + { + options.MaximumKeyLength = maximumKeyLength.GetValueOrDefault(); + } + + if (maximumPayloadBytes.HasValue) + { + options.MaximumPayloadBytes = maximumPayloadBytes.GetValueOrDefault(); + } + }); + services.AddLogging(options => + { + options.ClearProviders(); + options.AddProvider(collector); + }); using ServiceProvider provider = services.BuildServiceProvider(); var cache = Assert.IsType(provider.GetRequiredService()); - const string Key = "abc"; - string expected = "simple value"; - var actual = await cache.GetOrCreateAsync(Key, ct => new(new MutablePoco { Value = expected })); + var actual = await cache.GetOrCreateAsync(key!, ct => new(new MutablePoco { Value = expected })); // expect same contents Assert.Equal(expected, actual.Value); // rinse and repeat, to check we get the value from L1 - actual = await cache.GetOrCreateAsync(Key, ct => new(new MutablePoco { Value = Guid.NewGuid().ToString() })); + actual = await cache.GetOrCreateAsync(key!, ct => new(new MutablePoco { Value = Guid.NewGuid().ToString() })); if (expectFromL1) { @@ -86,10 +139,217 @@ public async Task ValidateSizeLimit_Mutable(long? sizeLimit, bool expectFromL1) // L1 cache not used Assert.NotEqual(expected, actual.Value); } + + collector.WriteTo(log); + collector.AssertErrors(errorIds); + } + + [Theory] + [InlineData("some value", false, 1, 1, 2, false)] + [InlineData("read fail", false, 1, 1, 1, true, Log.IdDeserializationFailure)] + [InlineData("write fail", true, 1, 1, 0, true, Log.IdSerializationFailure)] + public async Task BrokenSerializer_Mutable(string value, bool same, int runCount, int serializeCount, int deserializeCount, bool expectKnownFailure, params int[] errorIds) + { + using var collector = new LogCollector(); + var services = new ServiceCollection(); + services.AddMemoryCache(); + services.AddSingleton(); + var serializer = new MutablePoco.Serializer(); + services.AddHybridCache().AddSerializer(serializer); + services.AddLogging(options => + { + options.ClearProviders(); + options.AddProvider(collector); + }); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + int actualRunCount = 0; + Func> func = _ => + { + Interlocked.Increment(ref actualRunCount); + return new(new MutablePoco { Value = value }); + }; + + if (expectKnownFailure) + { + await Assert.ThrowsAsync(async () => await cache.GetOrCreateAsync("key", func)); + } + else + { + var first = await cache.GetOrCreateAsync("key", func); + var second = await cache.GetOrCreateAsync("key", func); + Assert.Equal(value, first.Value); + Assert.Equal(value, second.Value); + + if (same) + { + Assert.Same(first, second); + } + else + { + Assert.NotSame(first, second); + } + } + + Assert.Equal(runCount, Volatile.Read(ref actualRunCount)); + Assert.Equal(serializeCount, serializer.WriteCount); + Assert.Equal(deserializeCount, serializer.ReadCount); + collector.WriteTo(log); + collector.AssertErrors(errorIds); + } + + [Theory] + [InlineData("some value", true, 1, 1, 0, false, true)] + [InlineData("read fail", true, 1, 1, 0, false, true)] + [InlineData("write fail", true, 1, 1, 0, true, true, Log.IdSerializationFailure)] + + // without L2, we only need the serializer for sizing purposes (L1), not used for deserialize + [InlineData("some value", true, 1, 1, 0, false, false)] + [InlineData("read fail", true, 1, 1, 0, false, false)] + [InlineData("write fail", true, 1, 1, 0, true, false, Log.IdSerializationFailure)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Test scenario range; reducing duplication")] + public async Task BrokenSerializer_Immutable(string value, bool same, int runCount, int serializeCount, int deserializeCount, bool expectKnownFailure, bool withL2, + params int[] errorIds) + { + using var collector = new LogCollector(); + var services = new ServiceCollection(); + services.AddMemoryCache(); + if (withL2) + { + services.AddSingleton(); + } + + var serializer = new ImmutablePoco.Serializer(); + services.AddHybridCache().AddSerializer(serializer); + services.AddLogging(options => + { + options.ClearProviders(); + options.AddProvider(collector); + }); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + int actualRunCount = 0; + Func> func = _ => + { + Interlocked.Increment(ref actualRunCount); + return new(new ImmutablePoco(value)); + }; + + if (expectKnownFailure) + { + await Assert.ThrowsAsync(async () => await cache.GetOrCreateAsync("key", func)); + } + else + { + var first = await cache.GetOrCreateAsync("key", func); + var second = await cache.GetOrCreateAsync("key", func); + Assert.Equal(value, first.Value); + Assert.Equal(value, second.Value); + + if (same) + { + Assert.Same(first, second); + } + else + { + Assert.NotSame(first, second); + } + } + + Assert.Equal(runCount, Volatile.Read(ref actualRunCount)); + Assert.Equal(serializeCount, serializer.WriteCount); + Assert.Equal(deserializeCount, serializer.ReadCount); + collector.WriteTo(log); + collector.AssertErrors(errorIds); + } + + public class KnownFailureException : Exception + { + public KnownFailureException(string message) + : base(message) + { + } } public class MutablePoco { public string Value { get; set; } = ""; + + public sealed class Serializer : IHybridCacheSerializer + { + private int _readCount; + private int _writeCount; + + public int ReadCount => Volatile.Read(ref _readCount); + public int WriteCount => Volatile.Read(ref _writeCount); + + public MutablePoco Deserialize(ReadOnlySequence source) + { + Interlocked.Increment(ref _readCount); + var value = InbuiltTypeSerializer.DeserializeString(source); + if (value == "read fail") + { + throw new KnownFailureException("read failure"); + } + + return new MutablePoco { Value = value }; + } + + public void Serialize(MutablePoco value, IBufferWriter target) + { + Interlocked.Increment(ref _writeCount); + if (value.Value == "write fail") + { + throw new KnownFailureException("write failure"); + } + + InbuiltTypeSerializer.SerializeString(value.Value, target); + } + } + } + + [ImmutableObject(true)] + public sealed class ImmutablePoco + { + public ImmutablePoco(string value) + { + Value = value; + } + + public string Value { get; } + + public sealed class Serializer : IHybridCacheSerializer + { + private int _readCount; + private int _writeCount; + + public int ReadCount => Volatile.Read(ref _readCount); + public int WriteCount => Volatile.Read(ref _writeCount); + + public ImmutablePoco Deserialize(ReadOnlySequence source) + { + Interlocked.Increment(ref _readCount); + var value = InbuiltTypeSerializer.DeserializeString(source); + if (value == "read fail") + { + throw new KnownFailureException("read failure"); + } + + return new ImmutablePoco(value); + } + + public void Serialize(ImmutablePoco value, IBufferWriter target) + { + Interlocked.Increment(ref _writeCount); + if (value.Value == "write fail") + { + throw new KnownFailureException("write failure"); + } + + InbuiltTypeSerializer.SerializeString(value.Value, target); + } + } } } diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs new file mode 100644 index 00000000000..ecb97ef3c7e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs @@ -0,0 +1,189 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.Tracing; +using System.Globalization; +using Microsoft.Extensions.Caching.Hybrid.Internal; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public sealed class TestEventListener : EventListener +{ + // captures both event and counter data + + // this is used as a class fixture from HybridCacheEventSourceTests, because there + // seems to be some unpredictable behaviours if multiple event sources/listeners are + // casually created etc + private const double EventCounterIntervalSec = 0.25; + + private readonly List<(int id, string name, EventLevel level)> _events = []; + private readonly Dictionary _counters = []; + + private object SyncLock => _events; + + internal HybridCacheEventSource Source { get; } = new(); + + public TestEventListener Reset(bool resetCounters = true) + { + lock (SyncLock) + { + _events.Clear(); + _counters.Clear(); + + if (resetCounters) + { + Source.ResetCounters(); + } + } + + Assert.True(Source.IsEnabled(), "should report as enabled"); + + return this; + } + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (ReferenceEquals(eventSource, Source)) + { + var args = new Dictionary + { + ["EventCounterIntervalSec"] = EventCounterIntervalSec.ToString("G", CultureInfo.InvariantCulture), + }; + EnableEvents(Source, EventLevel.Verbose, EventKeywords.All, args); + } + + base.OnEventSourceCreated(eventSource); + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (ReferenceEquals(eventData.EventSource, Source)) + { + // capture counters/events + lock (SyncLock) + { + if (eventData.EventName == "EventCounters" + && eventData.Payload is { Count: > 0 }) + { + foreach (var payload in eventData.Payload) + { + if (payload is IDictionary map) + { + string? name = null; + string? displayName = null; + double? value = null; + bool isIncrement = false; + foreach (var pair in map) + { + switch (pair.Key) + { + case "Name" when pair.Value is string: + name = (string)pair.Value; + break; + case "DisplayName" when pair.Value is string s: + displayName = s; + break; + case "Mean": + isIncrement = false; + value = Convert.ToDouble(pair.Value); + break; + case "Increment": + isIncrement = true; + value = Convert.ToDouble(pair.Value); + break; + } + } + + if (name is not null && value is not null) + { + if (isIncrement && _counters.TryGetValue(name, out var oldPair)) + { + value += oldPair.value; // treat as delta from old + } + + Debug.WriteLine($"{name}={value}"); + _counters[name] = (displayName, value.Value); + } + } + } + } + else + { + _events.Add((eventData.EventId, eventData.EventName ?? "", eventData.Level)); + } + } + } + + base.OnEventWritten(eventData); + } + + public (int id, string name, EventLevel level) SingleEvent() + { + (int id, string name, EventLevel level) evt; + lock (SyncLock) + { + evt = Assert.Single(_events); + } + + return evt; + } + + public void AssertSingleEvent(int id, string name, EventLevel level) + { + var evt = SingleEvent(); + Assert.Equal(name, evt.name); + Assert.Equal(id, evt.id); + Assert.Equal(level, evt.level); + } + + public double AssertCounter(string name, string displayName) + { + lock (SyncLock) + { + Assert.True(_counters.TryGetValue(name, out var pair), $"counter not found: {name}"); + Assert.Equal(displayName, pair.displayName); + + _counters.Remove(name); // count as validated + return pair.value; + } + } + + public void AssertCounter(string name, string displayName, double expected) + { + var actual = AssertCounter(name, displayName); + if (!Equals(expected, actual)) + { + Assert.Fail($"{name}: expected {expected}, actual {actual}"); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Bug", "S1244:Floating point numbers should not be tested for equality", Justification = "Test expects exact zero")] + public void AssertRemainingCountersZero() + { + lock (SyncLock) + { + foreach (var pair in _counters) + { + if (pair.Value.value != 0) + { + Assert.Fail($"{pair.Key}: expected 0, actual {pair.Value.value}"); + } + } + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Clarity and usability")] + public async Task TryAwaitCountersAsync() + { + // allow 2 cycles because if we only allow 1, we run the risk of a + // snapshot being captured mid-cycle when we were setting up the test + // (ok, that's an unlikely race condition, but!) + await Task.Delay(TimeSpan.FromSeconds(EventCounterIntervalSec * 2)); + + lock (SyncLock) + { + return _counters.Count; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/UnreliableL2Tests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/UnreliableL2Tests.cs new file mode 100644 index 00000000000..7af85f9cba2 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/UnreliableL2Tests.cs @@ -0,0 +1,251 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +// validate HC stability when the L2 is unreliable +public class UnreliableL2Tests(ITestOutputHelper testLog) +{ + [Theory] + [InlineData(BreakType.None)] + [InlineData(BreakType.Synchronous, Log.IdCacheBackendWriteFailure)] + [InlineData(BreakType.Asynchronous, Log.IdCacheBackendWriteFailure)] + [InlineData(BreakType.AsynchronousYield, Log.IdCacheBackendWriteFailure)] + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Intentional; tracking for out-of-band support only")] + public async Task WriteFailureInvisible(BreakType writeBreak, params int[] errorIds) + { + using (GetServices(out var hc, out var l1, out var l2, out var log)) + using (log) + { + // normal behaviour when working fine + var x = await hc.GetOrCreateAsync("x", NewGuid); + Assert.Equal(x, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.NotNull(l2.Tail.Get("x")); // exists + + l2.WriteBreak = writeBreak; + var y = await hc.GetOrCreateAsync("y", NewGuid); + Assert.Equal(y, await hc.GetOrCreateAsync("y", NewGuid)); + if (writeBreak == BreakType.None) + { + Assert.NotNull(l2.Tail.Get("y")); // exists + } + else + { + Assert.Null(l2.Tail.Get("y")); // does not exist + } + + await l2.LastWrite; // allows out-of-band write to complete + await Task.Delay(150); // even then: thread jitter can cause problems + + log.WriteTo(testLog); + log.AssertErrors(errorIds); + } + } + + [Theory] + [InlineData(BreakType.None)] + [InlineData(BreakType.Synchronous, Log.IdCacheBackendReadFailure, Log.IdCacheBackendReadFailure)] + [InlineData(BreakType.Asynchronous, Log.IdCacheBackendReadFailure, Log.IdCacheBackendReadFailure)] + [InlineData(BreakType.AsynchronousYield, Log.IdCacheBackendReadFailure, Log.IdCacheBackendReadFailure)] + public async Task ReadFailureInvisible(BreakType readBreak, params int[] errorIds) + { + using (GetServices(out var hc, out var l1, out var l2, out var log)) + using (log) + { + // create two new values via HC; this should go down to l2 + var x = await hc.GetOrCreateAsync("x", NewGuid); + var y = await hc.GetOrCreateAsync("y", NewGuid); + + // this should be reliable and repeatable + Assert.Equal(x, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.Equal(y, await hc.GetOrCreateAsync("y", NewGuid)); + + // even if we clean L1, causing new L2 fetches + l1.Clear(); + Assert.Equal(x, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.Equal(y, await hc.GetOrCreateAsync("y", NewGuid)); + + // now we break L2 in some predictable way, *without* clearing L1 - the + // values should still be available via L1 + l2.ReadBreak = readBreak; + Assert.Equal(x, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.Equal(y, await hc.GetOrCreateAsync("y", NewGuid)); + + // but if we clear L1 to force L2 hits, we anticipate problems + l1.Clear(); + if (readBreak == BreakType.None) + { + Assert.Equal(x, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.Equal(y, await hc.GetOrCreateAsync("y", NewGuid)); + } + else + { + // because L2 is unavailable and L1 is empty, we expect the callback + // to be used again, generating new values + var a = await hc.GetOrCreateAsync("x", NewGuid, NoL2Write); + var b = await hc.GetOrCreateAsync("y", NewGuid, NoL2Write); + + Assert.NotEqual(x, a); + Assert.NotEqual(y, b); + + // but those *new* values are at least reliable inside L1 + Assert.Equal(a, await hc.GetOrCreateAsync("x", NewGuid)); + Assert.Equal(b, await hc.GetOrCreateAsync("y", NewGuid)); + } + + log.WriteTo(testLog); + log.AssertErrors(errorIds); + } + } + + private static HybridCacheEntryOptions NoL2Write { get; } = new HybridCacheEntryOptions { Flags = HybridCacheEntryFlags.DisableDistributedCacheWrite }; + + public enum BreakType + { + None, // async API works correctly + Synchronous, // async API faults directly rather than return a faulted task + Asynchronous, // async API returns a completed asynchronous fault + AsynchronousYield, // async API returns an incomplete asynchronous fault + } + + private static ValueTask NewGuid(CancellationToken cancellationToken) => new(Guid.NewGuid()); + + private static IDisposable GetServices(out HybridCache hc, out MemoryCache l1, + out UnreliableDistributedCache l2, out LogCollector log) + { + // we need an entirely separate MC for the dummy backend, not connected to our + // "real" services + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + var backend = services.BuildServiceProvider().GetRequiredService(); + + // now create the "real" services + l2 = new UnreliableDistributedCache(backend); + var collector = new LogCollector(); + log = collector; + services = new ServiceCollection(); + services.AddSingleton(l2); + services.AddHybridCache(); + services.AddLogging(options => + { + options.ClearProviders(); + options.AddProvider(collector); + }); + var lifetime = services.BuildServiceProvider(); + hc = lifetime.GetRequiredService(); + l1 = Assert.IsType(lifetime.GetRequiredService()); + return lifetime; + } + + private sealed class UnreliableDistributedCache : IDistributedCache + { + public UnreliableDistributedCache(IDistributedCache tail) + { + Tail = tail; + } + + public IDistributedCache Tail { get; } + public BreakType ReadBreak { get; set; } + public BreakType WriteBreak { get; set; } + + public Task LastWrite { get; private set; } = Task.CompletedTask; + + public byte[]? Get(string key) => throw new NotSupportedException(); // only async API in use + + public Task GetAsync(string key, CancellationToken token = default) + => TrackLast(ThrowIfBrokenAsync(ReadBreak) ?? Tail.GetAsync(key, token)); + + public void Refresh(string key) => throw new NotSupportedException(); // only async API in use + + public Task RefreshAsync(string key, CancellationToken token = default) + => TrackLast(ThrowIfBrokenAsync(WriteBreak) ?? Tail.RefreshAsync(key, token)); + + public void Remove(string key) => throw new NotSupportedException(); // only async API in use + + public Task RemoveAsync(string key, CancellationToken token = default) + => TrackLast(ThrowIfBrokenAsync(WriteBreak) ?? Tail.RemoveAsync(key, token)); + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => throw new NotSupportedException(); // only async API in use + + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + => TrackLast(ThrowIfBrokenAsync(WriteBreak) ?? Tail.SetAsync(key, value, options, token)); + + [DoesNotReturn] + private static void Throw() => throw new IOException("L2 offline"); + + private static async Task ThrowAsync(bool yield) + { + if (yield) + { + await Task.Yield(); + } + + Throw(); + return default; // never reached + } + + private static Task? ThrowIfBrokenAsync(BreakType breakType) => ThrowIfBrokenAsync(breakType); + + [SuppressMessage("Critical Bug", "S4586:Non-async \"Task/Task\" methods should not return null", Justification = "Intentional for propagation")] + private static Task? ThrowIfBrokenAsync(BreakType breakType) + { + switch (breakType) + { + case BreakType.Asynchronous: + return ThrowAsync(false); + case BreakType.AsynchronousYield: + return ThrowAsync(true); + case BreakType.None: + return null; + default: + // includes BreakType.Synchronous and anything unknown + Throw(); + break; + } + + return null; + } + + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Intentional; tracking for out-of-band support only")] + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "We don't need the failure type - just the timing")] + private static Task IgnoreFailure(Task task) + { + return task.Status == TaskStatus.RanToCompletion + ? Task.CompletedTask : IgnoreAsync(task); + + static async Task IgnoreAsync(Task task) + { + try + { + await task; + } + catch + { + // we only care about the "when"; failure is fine + } + } + } + + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Intentional; tracking for out-of-band support only")] + private Task TrackLast(Task lastWrite) + { + LastWrite = IgnoreFailure(lastWrite); + return lastWrite; + } + + [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Intentional; tracking for out-of-band support only")] + private Task TrackLast(Task lastWrite) + { + LastWrite = IgnoreFailure(lastWrite); + return lastWrite; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Abstractions.Tests/Microsoft.Extensions.Telemetry.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.Telemetry.Abstractions.Tests/Microsoft.Extensions.Telemetry.Abstractions.Tests.csproj index ac284fee861..387cec3c5c0 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Abstractions.Tests/Microsoft.Extensions.Telemetry.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Abstractions.Tests/Microsoft.Extensions.Telemetry.Abstractions.Tests.csproj @@ -12,4 +12,8 @@ + + + + From 5bf9f9fd9d11ea624113d4d9dc46ae1468667e4a Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Wed, 6 Nov 2024 08:57:07 +1100 Subject: [PATCH 094/190] Assign ownership (#5600) Resolves #4656 --- .github/CODEOWNERS | 123 ++++++++++++++---- .../Microsoft.Analyzers.Extra.csproj | 2 +- .../Microsoft.Analyzers.Local.csproj | 2 +- .../Microsoft.Gen.ComplianceReports.csproj | 2 +- .../Microsoft.Gen.ContextualOptions.csproj | 2 +- .../Microsoft.AspNetCore.Testing.csproj | 2 +- ...ensions.AmbientMetadata.Application.csproj | 2 +- ...Microsoft.Extensions.Caching.Hybrid.csproj | 13 +- ....Extensions.Compliance.Abstractions.csproj | 2 +- ...oft.Extensions.Compliance.Redaction.csproj | 2 +- ...osoft.Extensions.Compliance.Testing.csproj | 2 +- ...ons.Diagnostics.HealthChecks.Common.csproj | 2 +- ...cs.HealthChecks.ResourceUtilization.csproj | 2 +- ...ions.Diagnostics.ResourceMonitoring.csproj | 2 +- ...icrosoft.Extensions.Hosting.Testing.csproj | 2 +- ...osoft.Extensions.Options.Contextual.csproj | 2 +- .../Microsoft.Extensions.AuditReports.csproj | 2 +- ...Microsoft.Extensions.StaticAnalysis.csproj | 2 +- 18 files changed, 122 insertions(+), 46 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d6517452658..6f2e006013f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,28 +1,101 @@ # These owners will be the default owners for everything in the repo. Unless a later match takes precedence, # @dotnet/dotnet-extensions-fundamentals will be requested for review when someone opens a pull request. -*.cmd @dotnet/dotnet-extensions-infra -*.sh @dotnet/dotnet-extensions-infra -*.ps1 @dotnet/dotnet-extensions-infra -*.yml @dotnet/dotnet-extensions-infra -*.props @dotnet/dotnet-extensions-infra -*.targets @dotnet/dotnet-extensions-infra -/global.json @dotnet/dotnet-extensions-infra -/.azure/ @dotnet/dotnet-extensions-infra -/.azuredevops/ @dotnet/dotnet-extensions-infra -/.config/ @dotnet/dotnet-extensions-infra -/.devcontainer/ @dotnet/dotnet-extensions-infra -/.vscode/ @dotnet/dotnet-extensions-infra -/.github/ @dotnet/dotnet-extensions-infra -/docs/ @dotnet/dotnet-extensions-infra -/eng/ @dotnet/dotnet-extensions-infra - -/src/Libraries/Microsoft.Extensions.AI @dotnet/dotnet-extensions-ai -/src/Libraries/Microsoft.Extensions.AI.* @dotnet/dotnet-extensions-ai -/test/Libraries/Microsoft.Extensions.AI @dotnet/dotnet-extensions-ai -/test/Libraries/Microsoft.Extensions.AI.* @dotnet/dotnet-extensions-ai - -/src/Libraries/Microsoft.Extensions.Caching.Hybrid @dotnet/dotnet-extensions-caching-hybrid -/src/Libraries/Microsoft.Extensions.Caching.Hybrid.* @dotnet/dotnet-extensions-caching-hybrid -/test/Libraries/Microsoft.Extensions.Caching.Hybrid @dotnet/dotnet-extensions-caching-hybrid -/test/Libraries/Microsoft.Extensions.Caching.Hybrid.* @dotnet/dotnet-extensions-caching-hybrid +*.cmd @dotnet/dotnet-extensions-infra +*.sh @dotnet/dotnet-extensions-infra +*.ps1 @dotnet/dotnet-extensions-infra +*.yml @dotnet/dotnet-extensions-infra +*.props @dotnet/dotnet-extensions-infra +*.targets @dotnet/dotnet-extensions-infra +/global.json @dotnet/dotnet-extensions-infra +/.azure/ @dotnet/dotnet-extensions-infra +/.azuredevops/ @dotnet/dotnet-extensions-infra +/.config/ @dotnet/dotnet-extensions-infra +/.devcontainer/ @dotnet/dotnet-extensions-infra +/.vscode/ @dotnet/dotnet-extensions-infra +/.github/ @dotnet/dotnet-extensions-infra +/docs/ @dotnet/dotnet-extensions-infra +/eng/ @dotnet/dotnet-extensions-infra + +/src/Libraries/Microsoft.Extensions.AI @dotnet/dotnet-extensions-ai +/src/Libraries/Microsoft.Extensions.AI.* @dotnet/dotnet-extensions-ai +/test/Libraries/Microsoft.Extensions.AI @dotnet/dotnet-extensions-ai +/test/Libraries/Microsoft.Extensions.AI.* @dotnet/dotnet-extensions-ai + +/src/Libraries/Microsoft.Extensions.Caching.Hybrid @dotnet/dotnet-extensions-caching-hybrid +/src/Libraries/Microsoft.Extensions.Caching.Hybrid.* @dotnet/dotnet-extensions-caching-hybrid +/test/Libraries/Microsoft.Extensions.Caching.Hybrid @dotnet/dotnet-extensions-caching-hybrid +/test/Libraries/Microsoft.Extensions.Caching.Hybrid.* @dotnet/dotnet-extensions-caching-hybrid + +/src/Analyzers/Microsoft.Analyzers.Extra @dotnet/dotnet-extensions-analyzers +/src/Analyzers/Microsoft.Analyzers.Local @dotnet/dotnet-extensions-analyzers +/src/Packages/Microsoft.Extensions.StaticAnalysis @dotnet/dotnet-extensions-analyzers +/test/Analyzers/Microsoft.Analyzers.Extra.* @dotnet/dotnet-extensions-analyzers +/test/Analyzers/Microsoft.Analyzers.Local.* @dotnet/dotnet-extensions-analyzers + +/src/Generators/Microsoft.Gen.ComplianceReports @dotnet/dotnet-extensions-compliance +/src/Libraries/Microsoft.Extensions.Compliance.Abstractions @dotnet/dotnet-extensions-compliance +/src/Libraries/Microsoft.Extensions.Compliance.Redaction @dotnet/dotnet-extensions-compliance +/src/Libraries/Microsoft.Extensions.Compliance.Testing @dotnet/dotnet-extensions-compliance +/src/Packages/Microsoft.Extensions.AuditReports @dotnet/dotnet-extensions-compliance +/test/Generators/Microsoft.Gen.ComplianceReports @dotnet/dotnet-extensions-compliance +/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.* @dotnet/dotnet-extensions-compliance +/test/Libraries/Microsoft.Extensions.Compliance.Redaction.* @dotnet/dotnet-extensions-compliance +/test/Libraries/Microsoft.Extensions.Compliance.Testing.* @dotnet/dotnet-extensions-compliance + +/src/Generators/Microsoft.Gen.ContextualOptions @dotnet/dotnet-extensions-configuration +/src/Libraries/Microsoft.Extensions.Options.Contextual @dotnet/dotnet-extensions-configuration +/test/Generators/Microsoft.Gen.ContextualOptions @dotnet/dotnet-extensions-configuration +/test/Libraries/Microsoft.Extensions.Options.Contextual.* @dotnet/dotnet-extensions-configuration + +/src/Libraries/Microsoft.AspNetCore.AsyncState @dotnet/dotnet-extensions-fundamentals +/src/Libraries/Microsoft.AspNetCore.HeaderParsing @dotnet/dotnet-extensions-fundamentals +/src/Libraries/Microsoft.Extensions.AsyncState @dotnet/dotnet-extensions-fundamentals +/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation @dotnet/dotnet-extensions-fundamentals +/src/Libraries/Microsoft.Extensions.ObjectPool.DependencyInjection @dotnet/dotnet-extensions-fundamentals +/src/Libraries/Microsoft.Extensions.TimeProvider.Testing @dotnet/dotnet-extensions-fundamentals +/src/Shared @dotnet/dotnet-extensions-fundamentals +/test/Libraries/Microsoft.AspNetCore.AsyncState.* @dotnet/dotnet-extensions-fundamentals +/test/Libraries/Microsoft.AspNetCore.HeaderParsing.* @dotnet/dotnet-extensions-fundamentals +/test/Libraries/Microsoft.Extensions.AsyncState.* @dotnet/dotnet-extensions-fundamentals +/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.* @dotnet/dotnet-extensions-fundamentals +/test/Libraries/Microsoft.Extensions.ObjectPool.DependencyInjection.* @dotnet/dotnet-extensions-fundamentals +/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.* @dotnet/dotnet-extensions-fundamentals +/test/Shared.* @dotnet/dotnet-extensions-fundamentals + +/src/Libraries/Microsoft.AspNetCore.Testing @dotnet/dotnet-extensions-hosting +/src/Libraries/Microsoft.Extensions.Hosting.Testing @dotnet/dotnet-extensions-hosting +/test/Libraries/Microsoft.AspNetCore.Testing.* @dotnet/dotnet-extensions-hosting +/test/Libraries/Microsoft.Extensions.Hosting.Testing.* @dotnet/dotnet-extensions-hosting + +/src/Libraries/Microsoft.Extensions.Diagnostics.Probes @dotnet/dotnet-extensions-resilience +/src/Libraries/Microsoft.Extensions.Http.Resilience @dotnet/dotnet-extensions-resilience +/src/Libraries/Microsoft.Extensions.Resilience @dotnet/dotnet-extensions-resilience +/test/Libraries/Microsoft.Extensions.Diagnostics.Probes.* @dotnet/dotnet-extensions-resilience +/test/Libraries/Microsoft.Extensions.Http.Resilience.* @dotnet/dotnet-extensions-resilience +/test/Libraries/Microsoft.Extensions.Resilience.* @dotnet/dotnet-extensions-resilience + +/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.* @dotnet/dotnet-extensions-resourcemonitoring +/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring @dotnet/dotnet-extensions-resourcemonitoring +/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.* @dotnet/dotnet-extensions-resourcemonitoring +/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.* @dotnet/dotnet-extensions-resourcemonitoring + +/src/Generators/Microsoft.Gen.Logging @dotnet/dotnet-extensions-telemetry +/src/Generators/Microsoft.Gen.Metrics @dotnet/dotnet-extensions-telemetry +/src/Generators/Microsoft.Gen.MetricsReports @dotnet/dotnet-extensions-telemetry +/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware @dotnet/dotnet-extensions-telemetry +/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application @dotnet/dotnet-extensions-telemetry +/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization @dotnet/dotnet-extensions-telemetry +/src/Libraries/Microsoft.Extensions.Diagnostics.Testing @dotnet/dotnet-extensions-telemetry +/src/Libraries/Microsoft.Extensions.Http.Diagnostics @dotnet/dotnet-extensions-telemetry +/src/Libraries/Microsoft.Extensions.Telemetry.* @dotnet/dotnet-extensions-telemetry +/src/Libraries/Microsoft.Extensions.Telemetry @dotnet/dotnet-extensions-telemetry +/test/Generators/Microsoft.Gen.Logging @dotnet/dotnet-extensions-telemetry +/test/Generators/Microsoft.Gen.Metrics @dotnet/dotnet-extensions-telemetry +/test/Generators/Microsoft.Gen.MetricsReports @dotnet/dotnet-extensions-telemetry +/test/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware.* @dotnet/dotnet-extensions-telemetry +/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.* @dotnet/dotnet-extensions-telemetry +/test/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization.* @dotnet/dotnet-extensions-telemetry +/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.* @dotnet/dotnet-extensions-telemetry +/test/Libraries/Microsoft.Extensions.Http.Diagnostics.* @dotnet/dotnet-extensions-telemetry +/test/Libraries/Microsoft.Extensions.Telemetry.* @dotnet/dotnet-extensions-telemetry diff --git a/src/Analyzers/Microsoft.Analyzers.Extra/Microsoft.Analyzers.Extra.csproj b/src/Analyzers/Microsoft.Analyzers.Extra/Microsoft.Analyzers.Extra.csproj index a35345834ac..3dae07f7925 100644 --- a/src/Analyzers/Microsoft.Analyzers.Extra/Microsoft.Analyzers.Extra.csproj +++ b/src/Analyzers/Microsoft.Analyzers.Extra/Microsoft.Analyzers.Extra.csproj @@ -2,7 +2,7 @@ Microsoft.Extensions.ExtraAnalyzers Code analyzers and fixers - Fundamentals + Analyzers diff --git a/src/Analyzers/Microsoft.Analyzers.Local/Microsoft.Analyzers.Local.csproj b/src/Analyzers/Microsoft.Analyzers.Local/Microsoft.Analyzers.Local.csproj index 4380ee70163..8ebfff98adb 100644 --- a/src/Analyzers/Microsoft.Analyzers.Local/Microsoft.Analyzers.Local.csproj +++ b/src/Analyzers/Microsoft.Analyzers.Local/Microsoft.Analyzers.Local.csproj @@ -2,7 +2,7 @@ Microsoft.Extensions.LocalAnalyzers Analyzers used only in this repo - Fundamentals + Analyzers Static Analysis diff --git a/src/Generators/Microsoft.Gen.ComplianceReports/Microsoft.Gen.ComplianceReports.csproj b/src/Generators/Microsoft.Gen.ComplianceReports/Microsoft.Gen.ComplianceReports.csproj index f8dc4c4ebab..0ef9b3d55a6 100644 --- a/src/Generators/Microsoft.Gen.ComplianceReports/Microsoft.Gen.ComplianceReports.csproj +++ b/src/Generators/Microsoft.Gen.ComplianceReports/Microsoft.Gen.ComplianceReports.csproj @@ -2,7 +2,7 @@ Microsoft.Gen.ComplianceReports Produces compliance reports based on data classification annotations in the code. - Fundamentals + Compliance diff --git a/src/Generators/Microsoft.Gen.ContextualOptions/Microsoft.Gen.ContextualOptions.csproj b/src/Generators/Microsoft.Gen.ContextualOptions/Microsoft.Gen.ContextualOptions.csproj index d3d1b73a0e0..bcc3dcb761b 100644 --- a/src/Generators/Microsoft.Gen.ContextualOptions/Microsoft.Gen.ContextualOptions.csproj +++ b/src/Generators/Microsoft.Gen.ContextualOptions/Microsoft.Gen.ContextualOptions.csproj @@ -2,7 +2,7 @@ Microsoft.Gen.ContextualOptions Code generator to support Microsoft.Extensions.Options.Contextual. - Fundamentals + Configuration diff --git a/src/Libraries/Microsoft.AspNetCore.Testing/Microsoft.AspNetCore.Testing.csproj b/src/Libraries/Microsoft.AspNetCore.Testing/Microsoft.AspNetCore.Testing.csproj index e7fd38cecca..01eb8bb974e 100644 --- a/src/Libraries/Microsoft.AspNetCore.Testing/Microsoft.AspNetCore.Testing.csproj +++ b/src/Libraries/Microsoft.AspNetCore.Testing/Microsoft.AspNetCore.Testing.csproj @@ -2,7 +2,7 @@ Microsoft.AspNetCore.Testing Test fakes for integration testing - Fundamentals + Hosting $(PackageTags);Testing 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 2603f0b42f4..f631a4047bb 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 @@ -2,7 +2,7 @@ Microsoft.Extensions.AmbientMetadata Runtime information provider for application-level ambient metadata. - Fundamentals + Telemetry diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj index dfa70cd121e..1f7b9ba95f9 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj @@ -12,11 +12,7 @@ true true true - dev - EXTEXP0018 - 75 - 50 - Fundamentals + CachingHybrid true @@ -27,6 +23,13 @@ false + + dev + EXTEXP0018 + 75 + 50 + + diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj index c83b7284da5..8135657485f 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Microsoft.Extensions.Compliance.Abstractions.csproj @@ -3,7 +3,7 @@ Microsoft.Extensions.Compliance $(NetCoreTargetFrameworks);netstandard2.0; Abstractions to help ensure compliant data management. - Fundamentals + Compliance diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Redaction/Microsoft.Extensions.Compliance.Redaction.csproj b/src/Libraries/Microsoft.Extensions.Compliance.Redaction/Microsoft.Extensions.Compliance.Redaction.csproj index d331d10ff32..79fbecf8c1e 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Redaction/Microsoft.Extensions.Compliance.Redaction.csproj +++ b/src/Libraries/Microsoft.Extensions.Compliance.Redaction/Microsoft.Extensions.Compliance.Redaction.csproj @@ -2,7 +2,7 @@ Microsoft.Extensions.Compliance.Redaction Redaction engine and canonical redactors. - Fundamentals + Compliance diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Testing/Microsoft.Extensions.Compliance.Testing.csproj b/src/Libraries/Microsoft.Extensions.Compliance.Testing/Microsoft.Extensions.Compliance.Testing.csproj index 85ebae934dc..f8e1abcebcf 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Testing/Microsoft.Extensions.Compliance.Testing.csproj +++ b/src/Libraries/Microsoft.Extensions.Compliance.Testing/Microsoft.Extensions.Compliance.Testing.csproj @@ -2,7 +2,7 @@ Microsoft.Extensions.Compliance.Testing Implementation of data classification and redaction designed for testing. - Fundamentals + Compliance $(PackageTags);Testing diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/Microsoft.Extensions.Diagnostics.HealthChecks.Common.csproj b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/Microsoft.Extensions.Diagnostics.HealthChecks.Common.csproj index 9109e0df176..ce1df605e83 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/Microsoft.Extensions.Diagnostics.HealthChecks.Common.csproj +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/Microsoft.Extensions.Diagnostics.HealthChecks.Common.csproj @@ -2,7 +2,7 @@ Microsoft.Extensions.Diagnostics.HealthChecks Health check implementations. - Resilience + ResourceMonitoring diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.csproj b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.csproj index 183f4b21f41..2c193e256b2 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.csproj +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.csproj @@ -2,7 +2,7 @@ Microsoft.Extensions.Diagnostics.HealthChecks Resource utilization health check. - Resilience + ResourceMonitoring diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Microsoft.Extensions.Diagnostics.ResourceMonitoring.csproj b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Microsoft.Extensions.Diagnostics.ResourceMonitoring.csproj index 01f2387adaa..3e46b1f400d 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Microsoft.Extensions.Diagnostics.ResourceMonitoring.csproj +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Microsoft.Extensions.Diagnostics.ResourceMonitoring.csproj @@ -2,7 +2,7 @@ Microsoft.Extensions.Diagnostics.ResourceMonitoring Measures processor and memory usage. - Fundamentals + ResourceMonitoring diff --git a/src/Libraries/Microsoft.Extensions.Hosting.Testing/Microsoft.Extensions.Hosting.Testing.csproj b/src/Libraries/Microsoft.Extensions.Hosting.Testing/Microsoft.Extensions.Hosting.Testing.csproj index 1cc91384dcd..fdc40c84838 100644 --- a/src/Libraries/Microsoft.Extensions.Hosting.Testing/Microsoft.Extensions.Hosting.Testing.csproj +++ b/src/Libraries/Microsoft.Extensions.Hosting.Testing/Microsoft.Extensions.Hosting.Testing.csproj @@ -2,7 +2,7 @@ Microsoft.Extensions.Hosting Tools for integration testing of apps built with Microsoft.Extensions.Hosting - Fundamentals + Hosting $(PackageTags);Testing diff --git a/src/Libraries/Microsoft.Extensions.Options.Contextual/Microsoft.Extensions.Options.Contextual.csproj b/src/Libraries/Microsoft.Extensions.Options.Contextual/Microsoft.Extensions.Options.Contextual.csproj index 4f1c3825b48..d80898ce3ae 100644 --- a/src/Libraries/Microsoft.Extensions.Options.Contextual/Microsoft.Extensions.Options.Contextual.csproj +++ b/src/Libraries/Microsoft.Extensions.Options.Contextual/Microsoft.Extensions.Options.Contextual.csproj @@ -2,7 +2,7 @@ Microsoft.Extensions.Options.Contextual A common abstraction for contextual options. - Config and Experimentation + Configuration diff --git a/src/Packages/Microsoft.Extensions.AuditReports/Microsoft.Extensions.AuditReports.csproj b/src/Packages/Microsoft.Extensions.AuditReports/Microsoft.Extensions.AuditReports.csproj index e9a08ab521d..b7e8af3a8c8 100644 --- a/src/Packages/Microsoft.Extensions.AuditReports/Microsoft.Extensions.AuditReports.csproj +++ b/src/Packages/Microsoft.Extensions.AuditReports/Microsoft.Extensions.AuditReports.csproj @@ -1,7 +1,7 @@ Produces reports about the code being compiled which are useful during privacy and telemetry audits. - Fundamentals + Compliance diff --git a/src/Packages/Microsoft.Extensions.StaticAnalysis/Microsoft.Extensions.StaticAnalysis.csproj b/src/Packages/Microsoft.Extensions.StaticAnalysis/Microsoft.Extensions.StaticAnalysis.csproj index 1577599d25c..9f4d47e64c1 100644 --- a/src/Packages/Microsoft.Extensions.StaticAnalysis/Microsoft.Extensions.StaticAnalysis.csproj +++ b/src/Packages/Microsoft.Extensions.StaticAnalysis/Microsoft.Extensions.StaticAnalysis.csproj @@ -2,7 +2,7 @@ Microsoft.Extensions.StaticAnalysis A curated set of code analyzers and code analyzer settings. - Fundamentals + Analyzers Static Analysis From fdf418036ea676de918e19c0b7442572ddb6920a Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 6 Nov 2024 11:32:40 +0000 Subject: [PATCH 095/190] HybridCache: don't log cancellation as failure (#5601) --- .../DefaultHybridCache.StampedeStateT.cs | 20 ++++++++++++-- .../Internal/HybridCacheEventSource.cs | 18 +++++++++++++ .../HybridCacheEventSourceTests.cs | 27 +++++++++++++++++++ .../TestEventListener.cs | 2 +- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs index 4be5b351485..77322eecee6 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs @@ -190,6 +190,15 @@ private async Task BackgroundFetchAsync() } } } + catch (OperationCanceledException) when (SharedToken.IsCancellationRequested) + { + if (eventSourceEnabled) + { + HybridCacheEventSource.Log.DistributedCacheCanceled(); + } + + throw; // don't just treat as miss - exit ASAP + } catch (Exception ex) { if (eventSourceEnabled) @@ -227,11 +236,18 @@ private async Task BackgroundFetchAsync() HybridCacheEventSource.Log.UnderlyingDataQueryComplete(); } } - catch + catch (Exception ex) { if (eventSourceEnabled) { - HybridCacheEventSource.Log.UnderlyingDataQueryFailed(); + if (ex is OperationCanceledException && SharedToken.IsCancellationRequested) + { + HybridCacheEventSource.Log.UnderlyingDataQueryCanceled(); + } + else + { + HybridCacheEventSource.Log.UnderlyingDataQueryFailed(); + } } throw; diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs index 92a5d729e57..2db179cfc4c 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCacheEventSource.cs @@ -25,6 +25,8 @@ internal sealed class HybridCacheEventSource : EventSource internal const int EventIdLocalCacheWrite = 10; internal const int EventIdDistributedCacheWrite = 11; internal const int EventIdStampedeJoin = 12; + internal const int EventIdUnderlyingDataQueryCanceled = 13; + internal const int EventIdDistributedCacheCanceled = 14; // fast local counters private long _totalLocalCacheHit; @@ -117,6 +119,14 @@ public void DistributedCacheFailed() WriteEvent(EventIdDistributedCacheFailed); } + [Event(EventIdDistributedCacheCanceled, Level = EventLevel.Verbose)] + public void DistributedCacheCanceled() + { + DebugAssertEnabled(); + _ = Interlocked.Decrement(ref _currentDistributedFetch); + WriteEvent(EventIdDistributedCacheCanceled); + } + [Event(EventIdUnderlyingDataQueryStart, Level = EventLevel.Verbose)] public void UnderlyingDataQueryStart() { @@ -143,6 +153,14 @@ public void UnderlyingDataQueryFailed() WriteEvent(EventIdUnderlyingDataQueryFailed); } + [Event(EventIdUnderlyingDataQueryCanceled, Level = EventLevel.Verbose)] + public void UnderlyingDataQueryCanceled() + { + DebugAssertEnabled(); + _ = Interlocked.Decrement(ref _currentUnderlyingDataQuery); + WriteEvent(EventIdUnderlyingDataQueryCanceled); + } + [Event(EventIdLocalCacheWrite, Level = EventLevel.Verbose)] public void LocalCacheWrite() { diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs index 3a266af7ce3..74876053e34 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/HybridCacheEventSourceTests.cs @@ -99,6 +99,19 @@ public async Task DistributedCacheFailed() listener.AssertRemainingCountersZero(); } + [SkippableFact] + public async Task DistributedCacheCanceled() + { + AssertEnabled(); + + listener.Reset().Source.DistributedCacheGet(); + listener.Reset(resetCounters: false).Source.DistributedCacheCanceled(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdDistributedCacheCanceled, "DistributedCacheCanceled", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertRemainingCountersZero(); + } + [SkippableFact] public async Task UnderlyingDataQueryStart() { @@ -141,6 +154,20 @@ public async Task UnderlyingDataQueryFailed() listener.AssertRemainingCountersZero(); } + [SkippableFact] + public async Task UnderlyingDataQueryCanceled() + { + AssertEnabled(); + + listener.Reset().Source.UnderlyingDataQueryStart(); + listener.Reset(resetCounters: false).Source.UnderlyingDataQueryCanceled(); + listener.AssertSingleEvent(HybridCacheEventSource.EventIdUnderlyingDataQueryCanceled, "UnderlyingDataQueryCanceled", EventLevel.Verbose); + + await AssertCountersAsync(); + listener.AssertCounter("total-data-query", "Total Data Queries", 1); + listener.AssertRemainingCountersZero(); + } + [SkippableFact] public async Task LocalCacheWrite() { diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs index ecb97ef3c7e..b901503afa4 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TestEventListener.cs @@ -50,7 +50,7 @@ protected override void OnEventSourceCreated(EventSource eventSource) { ["EventCounterIntervalSec"] = EventCounterIntervalSec.ToString("G", CultureInfo.InvariantCulture), }; - EnableEvents(Source, EventLevel.Verbose, EventKeywords.All, args); + EnableEvents(Source, EventLevel.LogAlways, EventKeywords.All, args); } base.OnEventSourceCreated(eventSource); From f05cf03a40fc65444d01829b8e7dce4937dbca30 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 6 Nov 2024 08:24:50 -0500 Subject: [PATCH 096/190] Set DisableNETStandardCompatErrors for M.E.AI projects (#5603) --- .../Microsoft.Extensions.AI.Abstractions.csproj | 1 + .../Microsoft.Extensions.AI.AzureAIInference.csproj | 1 + .../Microsoft.Extensions.AI.Ollama.csproj | 1 + .../Microsoft.Extensions.AI.OpenAI.csproj | 1 + .../Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj | 1 + 5 files changed, 5 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index 30d5cd84425..f21aa057173 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -16,6 +16,7 @@ $(TargetFrameworks);netstandard2.0 $(NoWarn);CA2227;CA1034;SA1316;S3253 true + true 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 3f9489dbdc7..1b3e2c8da7d 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 @@ -16,6 +16,7 @@ $(TargetFrameworks);netstandard2.0 $(NoWarn);CA1063;CA2227;SA1316;S1067;S1121;S3358 true + true diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj index 81beb0d7bed..779324f4cce 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj @@ -16,6 +16,7 @@ $(TargetFrameworks);netstandard2.0 $(NoWarn);CA2227;SA1316;S1121;EA0002 true + true 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 67df978b7d4..53d1f78fd10 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 @@ -16,6 +16,7 @@ $(TargetFrameworks);netstandard2.0 $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002 true + true diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 2b91bf8d3a6..5f885adafe8 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -18,6 +18,7 @@ $(TargetFrameworks);netstandard2.0 $(NoWarn);CA2227;CA1034;SA1316;S1067;S1121;S1994;S3253 true + true From f902047c642f3913e666cb9d18208c90efe7ccaf Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 6 Nov 2024 08:24:50 -0500 Subject: [PATCH 097/190] Set DisableNETStandardCompatErrors for M.E.AI projects (#5603) --- .../Microsoft.Extensions.AI.Abstractions.csproj | 1 + .../Microsoft.Extensions.AI.AzureAIInference.csproj | 1 + .../Microsoft.Extensions.AI.Ollama.csproj | 1 + .../Microsoft.Extensions.AI.OpenAI.csproj | 1 + .../Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj | 1 + 5 files changed, 5 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index 30d5cd84425..f21aa057173 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -16,6 +16,7 @@ $(TargetFrameworks);netstandard2.0 $(NoWarn);CA2227;CA1034;SA1316;S3253 true + true 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 3f9489dbdc7..1b3e2c8da7d 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 @@ -16,6 +16,7 @@ $(TargetFrameworks);netstandard2.0 $(NoWarn);CA1063;CA2227;SA1316;S1067;S1121;S3358 true + true diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj index 81beb0d7bed..779324f4cce 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj @@ -16,6 +16,7 @@ $(TargetFrameworks);netstandard2.0 $(NoWarn);CA2227;SA1316;S1121;EA0002 true + true 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 67df978b7d4..53d1f78fd10 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 @@ -16,6 +16,7 @@ $(TargetFrameworks);netstandard2.0 $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002 true + true diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 2b91bf8d3a6..5f885adafe8 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -18,6 +18,7 @@ $(TargetFrameworks);netstandard2.0 $(NoWarn);CA2227;CA1034;SA1316;S1067;S1121;S1994;S3253 true + true From 1af24f570acea131c62e8aed6d94bcf7c915b7bc Mon Sep 17 00:00:00 2001 From: Darius Letterman Date: Thu, 7 Nov 2024 16:05:34 +0100 Subject: [PATCH 098/190] handle catch-all parameters (#5604) --- .../Http/HttpRouteParser.cs | 58 +++-- .../Http/Segment.cs | 9 +- .../Http/HttpParserTests.cs | 237 +++++++++++++++--- 3 files changed, 261 insertions(+), 43 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs index c62c50af154..6783785d838 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Compliance.Classification; using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Http.Diagnostics; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Http.Diagnostics; @@ -56,16 +57,22 @@ public bool TryExtractParameters( { var startIndex = segment.Start + offset; - string parameterValue; + // If we exceed a length of the http path it means that the appropriate http route + // has optional parameters or parameters with default values, and these parameters + // are omitted in the http path. In this case we return a default value of the + // omitted parameter. + string parameterValue = segment.DefaultValue; + bool isRedacted = false; if (startIndex < httpPathAsSpan.Length) { var parameterContent = segment.Content; var parameterTemplateLength = parameterContent.Length + 2; + var length = httpPathAsSpan.Slice(startIndex).IndexOf(ForwardSlash); - if (length == -1) + if (segment.IsCatchAll || length == -1) { length = httpPathAsSpan.Slice(startIndex).Length; } @@ -75,15 +82,6 @@ public bool TryExtractParameters( parameterValue = GetRedactedParameterValue(httpPathAsSpan, segment, startIndex, length, redactionMode, parametersToRedact, ref isRedacted); } - // If we exceed a length of the http path it means that the appropriate http route - // has optional parameters or parameters with default values, and these parameters - // are omitted in the http path. In this case we return a default value of the - // omitted parameter. - else - { - parameterValue = segment.DefaultValue; - } - httpRouteParameters[index++] = new HttpRouteParameter(segment.ParamName, parameterValue, isRedacted); } } @@ -157,6 +155,8 @@ private static Segment GetParameterSegment(string httpRoute, ref int pos) int start = pos++; int paramNameEnd = PositionNotFound; + int paramNameStart = start + 1; + bool catchAllParamFound = false; int defaultValueStart = PositionNotFound; char ch; @@ -187,13 +187,42 @@ private static Segment GetParameterSegment(string httpRoute, ref int pos) } } + // The segment has '*' catch all parameter. + // When we meet the character it indicates param start position needs to be adjusted, so that we capture 'param' instead of '*param' + // *param can only appear after opening curly brace and position needs to be adjusted only once + else if (!catchAllParamFound && ch == '*' && pos > 0 && httpRoute[pos - 1] == '{') + { + paramNameStart++; + + // Catch all parameters can start with one or two '*' characters. + if (httpRoute[paramNameStart] == '*') + { + paramNameStart++; + } + + catchAllParamFound = true; + } + pos++; } - string content = GetSegmentContent(httpRoute, start + 1, pos); + // Throw an ArgumentException if the segment is a catch-all parameter and not the last segment. + // The current position should be either the end of the route or the second to last position followed by a '/'. + if (catchAllParamFound) + { + bool isLastPosition = pos == httpRoute.Length - 1; + bool isSecondToLastPosition = pos == httpRoute.Length - 2; + + if (!(isLastPosition || (isSecondToLastPosition && httpRoute[pos + 1] == '/'))) + { + Throw.ArgumentException(nameof(httpRoute), "A catch-all parameter must be the last segment in the route."); + } + } + + string content = GetSegmentContent(httpRoute, paramNameStart, pos); string paramName = paramNameEnd == PositionNotFound ? content - : GetSegmentContent(httpRoute, start + 1, paramNameEnd); + : GetSegmentContent(httpRoute, paramNameStart, paramNameEnd); string defaultValue = defaultValueStart == PositionNotFound ? string.Empty : GetSegmentContent(httpRoute, defaultValueStart, pos); @@ -205,7 +234,8 @@ private static Segment GetParameterSegment(string httpRoute, ref int pos) content: content, isParam: true, paramName: paramName, - defaultValue: defaultValue); + defaultValue: defaultValue, + isCatchAll: catchAllParamFound); } private static string GetSegmentContent(string httpRoute, int start, int end) diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/Segment.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/Segment.cs index 76f68614ee0..44f7d1b66aa 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/Segment.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/Segment.cs @@ -24,9 +24,10 @@ internal readonly struct Segment /// If the segment is a param. /// Name of the parameter. /// Default value of the parameter. + /// If the segment is a catch-all parameter. public Segment( int start, int end, string content, bool isParam, - string paramName = "", string defaultValue = "") + string paramName = "", string defaultValue = "", bool isCatchAll = false) { Start = start; End = end; @@ -34,6 +35,7 @@ public Segment( IsParam = isParam; ParamName = paramName; DefaultValue = defaultValue; + IsCatchAll = isCatchAll; } /// @@ -66,6 +68,11 @@ public Segment( /// public string DefaultValue { get; } = string.Empty; + /// + /// Gets a value indicating whether the segment is a catch-all parameter. + /// + public bool IsCatchAll { get; } + internal static bool IsKnownUnredactableParameter(string parameter) => parameter.Equals(ControllerParameter, StringComparison.OrdinalIgnoreCase) || parameter.Equals(ActionParameter, StringComparison.OrdinalIgnoreCase); diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Http/HttpParserTests.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Http/HttpParserTests.cs index 6bee06a7c23..a3e40c53e38 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Http/HttpParserTests.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Http/HttpParserTests.cs @@ -377,6 +377,97 @@ public void TryExtractParameters_WhenRouteHasOptionalsAndConstraints_ReturnsExpe ValidateRouteParameter(httpRouteParameters[1], "chatId", "", false); } + [Theory] + [CombinatorialData] + public void TryExtractParameters_WhenRouteHasCatchAllParameter_ReturnsCorrectParameters( + bool routeHasMessageSegment, + bool roundTripSyntax, + HttpRouteParameterRedactionMode redactionMode) + { + bool isRedacted = redactionMode != HttpRouteParameterRedactionMode.None; + string redactedPrefix = isRedacted ? "Redacted:" : string.Empty; + + HttpRouteParser httpParser = CreateHttpRouteParser(); + Dictionary parametersToRedact = new() + { + { "routeId", FakeTaxonomy.PrivateData }, + { "chatId", FakeTaxonomy.PrivateData }, + { "catchAll", FakeTaxonomy.PrivateData }, + }; + + string httpPath = "api/routes/routeId123/chats/chatId123/messages/1/2/3/"; + + var paramName = "*catchAll"; + if (roundTripSyntax) + { + paramName = "**catchAll"; + } + + var expectedValue = "messages/1/2/3/"; + var segment = string.Empty; + if (routeHasMessageSegment) + { + segment = "/messages"; + expectedValue = "1/2/3/"; + } + + string httpRoute = $"api/routes/{{routeId}}/chats/{{chatId}}{segment}/{{{paramName}}}/"; + + var routeSegments = httpParser.ParseRoute(httpRoute); + var httpRouteParameters = new HttpRouteParameter[3]; + var success = httpParser.TryExtractParameters(httpPath, routeSegments, redactionMode, parametersToRedact, ref httpRouteParameters); + + Assert.True(success); + ValidateRouteParameter(httpRouteParameters[0], "routeId", $"{redactedPrefix}routeId123", isRedacted); + ValidateRouteParameter(httpRouteParameters[1], "chatId", $"{redactedPrefix}chatId123", isRedacted); + ValidateRouteParameter(httpRouteParameters[2], "catchAll", $"{redactedPrefix}{expectedValue}", isRedacted); + } + + [Theory] + [CombinatorialData] + public void TryExtractParameters_WhenRouteHasCatchAllParameter_Optional_ReturnsCorrectParameters( + bool routeHasDefaultValue, + bool useRoundTripSyntax, + HttpRouteParameterRedactionMode redactionMode) + { + bool isRedacted = redactionMode != HttpRouteParameterRedactionMode.None; + string redactedPrefix = isRedacted ? "Redacted:" : string.Empty; + + HttpRouteParser httpParser = CreateHttpRouteParser(); + Dictionary parametersToRedact = new() + { + { "routeId", FakeTaxonomy.PrivateData }, + { "chatId", FakeTaxonomy.PrivateData }, + { "catchAll", FakeTaxonomy.PrivateData }, + }; + + var httpPath = "api/routes/routeId123/chats/chatId123"; + + var paramName = "*catchAll"; + if (useRoundTripSyntax) + { + paramName = "**catchAll"; + } + + var expectedValue = string.Empty; + if (routeHasDefaultValue) + { + expectedValue = nameof(routeHasDefaultValue); + paramName += $"={expectedValue}"; + } + + var httpRoute = $"api/routes/{{routeId}}/chats/{{chatId}}/{{{paramName}}}"; + + var routeSegments = httpParser.ParseRoute(httpRoute); + var httpRouteParameters = new HttpRouteParameter[3]; + var success = httpParser.TryExtractParameters(httpPath, routeSegments, redactionMode, parametersToRedact, ref httpRouteParameters); + + Assert.True(success); + ValidateRouteParameter(httpRouteParameters[0], "routeId", $"{redactedPrefix}routeId123", isRedacted); + ValidateRouteParameter(httpRouteParameters[1], "chatId", $"{redactedPrefix}chatId123", isRedacted); + ValidateRouteParameter(httpRouteParameters[2], "catchAll", expectedValue, false); + } + [Fact] public void ParseRoute_WithRouteParameter_ReturnsRouteSegments() { @@ -389,10 +480,10 @@ public void ParseRoute_WithRouteParameter_ReturnsRouteSegments() Assert.Equal(4, routeSegments.Segments.Length); Assert.Equal("api/routes/{routeId}/chats/{chatId}", routeSegments.RouteTemplate); - ValidateRouteSegment(routeSegments.Segments[0], "api/routes/", false, "", "", 0, 11); - ValidateRouteSegment(routeSegments.Segments[1], "routeId", true, "routeId", "", 11, 20); - ValidateRouteSegment(routeSegments.Segments[2], "/chats/", false, "", "", 20, 27); - ValidateRouteSegment(routeSegments.Segments[3], "chatId", true, "chatId", "", 27, 35); + ValidateRouteSegment(routeSegments.Segments[0], ("api/routes/", false, "", "", 0, 11, false)); + ValidateRouteSegment(routeSegments.Segments[1], ("routeId", true, "routeId", "", 11, 20, false)); + ValidateRouteSegment(routeSegments.Segments[2], ("/chats/", false, "", "", 20, 27, false)); + ValidateRouteSegment(routeSegments.Segments[3], ("chatId", true, "chatId", "", 27, 35, false)); // An http route has parameters and ends with text. httpRoute = "/api/routes/{routeId}/chats/{chatId}/messages"; @@ -401,11 +492,11 @@ public void ParseRoute_WithRouteParameter_ReturnsRouteSegments() Assert.Equal(5, routeSegments.Segments.Length); Assert.Equal("api/routes/{routeId}/chats/{chatId}/messages", routeSegments.RouteTemplate); - ValidateRouteSegment(routeSegments.Segments[0], "api/routes/", false, "", "", 0, 11); - ValidateRouteSegment(routeSegments.Segments[1], "routeId", true, "routeId", "", 11, 20); - ValidateRouteSegment(routeSegments.Segments[2], "/chats/", false, "", "", 20, 27); - ValidateRouteSegment(routeSegments.Segments[3], "chatId", true, "chatId", "", 27, 35); - ValidateRouteSegment(routeSegments.Segments[4], "/messages", false, "", "", 35, 44); + ValidateRouteSegment(routeSegments.Segments[0], ("api/routes/", false, "", "", 0, 11, false)); + ValidateRouteSegment(routeSegments.Segments[1], ("routeId", true, "routeId", "", 11, 20, false)); + ValidateRouteSegment(routeSegments.Segments[2], ("/chats/", false, "", "", 20, 27, false)); + ValidateRouteSegment(routeSegments.Segments[3], ("chatId", true, "chatId", "", 27, 35, false)); + ValidateRouteSegment(routeSegments.Segments[4], ("/messages", false, "", "", 35, 44, false)); } [Fact] @@ -419,11 +510,11 @@ public void ParseRoute_WithQueryParameter_ReturnRouteSegmentExcludingQueryParams Assert.Equal(5, routeSegments.Segments.Length); Assert.Equal("api/routes/{routeId}/chats/{chatId}/messages", routeSegments.RouteTemplate); - ValidateRouteSegment(routeSegments.Segments[0], "api/routes/", false, "", "", 0, 11); - ValidateRouteSegment(routeSegments.Segments[1], "routeId", true, "routeId", "", 11, 20); - ValidateRouteSegment(routeSegments.Segments[2], "/chats/", false, "", "", 20, 27); - ValidateRouteSegment(routeSegments.Segments[3], "chatId", true, "chatId", "", 27, 35); - ValidateRouteSegment(routeSegments.Segments[4], "/messages", false, "", "", 35, 44); + ValidateRouteSegment(routeSegments.Segments[0], ("api/routes/", false, "", "", 0, 11, false)); + ValidateRouteSegment(routeSegments.Segments[1], ("routeId", true, "routeId", "", 11, 20, false)); + ValidateRouteSegment(routeSegments.Segments[2], ("/chats/", false, "", "", 20, 27, false)); + ValidateRouteSegment(routeSegments.Segments[3], ("chatId", true, "chatId", "", 27, 35, false)); + ValidateRouteSegment(routeSegments.Segments[4], ("/messages", false, "", "", 35, 44, false)); // Route doesn't start with forward slash, the final result should begin with forward slash. httpRoute = "api/routes/{routeId}/chats/{chatId}/messages?from=7"; @@ -432,11 +523,11 @@ public void ParseRoute_WithQueryParameter_ReturnRouteSegmentExcludingQueryParams Assert.Equal(5, routeSegments.Segments.Length); Assert.Equal("api/routes/{routeId}/chats/{chatId}/messages", routeSegments.RouteTemplate); - ValidateRouteSegment(routeSegments.Segments[0], "api/routes/", false, "", "", 0, 11); - ValidateRouteSegment(routeSegments.Segments[1], "routeId", true, "routeId", "", 11, 20); - ValidateRouteSegment(routeSegments.Segments[2], "/chats/", false, "", "", 20, 27); - ValidateRouteSegment(routeSegments.Segments[3], "chatId", true, "chatId", "", 27, 35); - ValidateRouteSegment(routeSegments.Segments[4], "/messages", false, "", "", 35, 44); + ValidateRouteSegment(routeSegments.Segments[0], ("api/routes/", false, "", "", 0, 11, false)); + ValidateRouteSegment(routeSegments.Segments[1], ("routeId", true, "routeId", "", 11, 20, false)); + ValidateRouteSegment(routeSegments.Segments[2], ("/chats/", false, "", "", 20, 27, false)); + ValidateRouteSegment(routeSegments.Segments[3], ("chatId", true, "chatId", "", 27, 35, false)); + ValidateRouteSegment(routeSegments.Segments[4], ("/messages", false, "", "", 35, 44, false)); } [Fact] @@ -450,14 +541,101 @@ public void ParseRoute_WhenRouteHasDefaultsOptionalsConstraints_ReturnsRouteSegm Assert.Equal(8, routeSegments.Segments.Length); Assert.Equal(httpRoute, routeSegments.RouteTemplate); - ValidateRouteSegment(routeSegments.Segments[0], "api/", false, "", "", 0, 4); - ValidateRouteSegment(routeSegments.Segments[1], "controller=home", true, "controller", "home", 4, 21); - ValidateRouteSegment(routeSegments.Segments[2], "/", false, "", "", 21, 22); - ValidateRouteSegment(routeSegments.Segments[3], "action=index", true, "action", "index", 22, 36); - ValidateRouteSegment(routeSegments.Segments[4], "/", false, "", "", 36, 37); - ValidateRouteSegment(routeSegments.Segments[5], "routeId:int:min(1)", true, "routeId", "", 37, 57); - ValidateRouteSegment(routeSegments.Segments[6], "/", false, "", "", 57, 58); - ValidateRouteSegment(routeSegments.Segments[7], "chatId?", true, "chatId", "", 58, 67); + ValidateRouteSegment(routeSegments.Segments[0], ("api/", false, "", "", 0, 4, false)); + ValidateRouteSegment(routeSegments.Segments[1], ("controller=home", true, "controller", "home", 4, 21, false)); + ValidateRouteSegment(routeSegments.Segments[2], ("/", false, "", "", 21, 22, false)); + ValidateRouteSegment(routeSegments.Segments[3], ("action=index", true, "action", "index", 22, 36, false)); + ValidateRouteSegment(routeSegments.Segments[4], ("/", false, "", "", 36, 37, false)); + ValidateRouteSegment(routeSegments.Segments[5], ("routeId:int:min(1)", true, "routeId", "", 37, 57, false)); + ValidateRouteSegment(routeSegments.Segments[6], ("/", false, "", "", 57, 58, false)); + ValidateRouteSegment(routeSegments.Segments[7], ("chatId?", true, "chatId", "", 58, 67, false)); + } + + [Theory] + [InlineData("api/{controller=home}/{action=index}/{*url}/{invalid}")] + [InlineData("api/{controller=home}/{action=index}/{**url}/{invalid}")] + public void ParseRoute_WhenRouteHasCatchAllParameter_OutOfOrder(string httpRoute) + { + HttpRouteParser httpParser = CreateHttpRouteParser(); + + var exception = Assert.Throws(() => httpParser.ParseRoute(httpRoute)); + + Assert.StartsWith("A catch-all parameter must be the last segment in the route.", exception.Message); + } + + [Theory] + [InlineData("api/{controller=home}/{action=index}/{*url}")] + [InlineData("api/{controller=home}/{action=index}/{*url}/")] + [InlineData("api/{controller=home}/{action=index}/{**url}")] + [InlineData("api/{controller=home}/{action=index}/{**url}/")] + public void ParseRoute_WhenRouteHasCatchAllParameter_InCorrectPosition(string httpRoute) + { + HttpRouteParser httpParser = CreateHttpRouteParser(); + + ParsedRouteSegments routeSegments = httpParser.ParseRoute(httpRoute); + + Assert.Equal(3, routeSegments.ParameterCount); + Assert.Equal(httpRoute, routeSegments.RouteTemplate); + } + + [Theory] + [InlineData("api/{controller=home}/{action=index}/{*url}", 37, 43)] + [InlineData("api/{controller=home}/{action=index}/{**url}", 37, 44)] + public void ParseRoute_WhenRouteHasCatchAllParameter_ReturnsRouteSegments(string httpRoute, int start, int end) + { + HttpRouteParser httpParser = CreateHttpRouteParser(); + + ParsedRouteSegments routeSegments = httpParser.ParseRoute(httpRoute); + + Assert.Equal(6, routeSegments.Segments.Length); + Assert.Equal(httpRoute, routeSegments.RouteTemplate); + + ValidateRouteSegment(routeSegments.Segments[0], ("api/", false, "", "", 0, 4, false)); + ValidateRouteSegment(routeSegments.Segments[1], ("controller=home", true, "controller", "home", 4, 21, false)); + ValidateRouteSegment(routeSegments.Segments[2], ("/", false, "", "", 21, 22, false)); + ValidateRouteSegment(routeSegments.Segments[3], ("action=index", true, "action", "index", 22, 36, false)); + ValidateRouteSegment(routeSegments.Segments[4], ("/", false, "", "", 36, 37, false)); + ValidateRouteSegment(routeSegments.Segments[5], ("url", true, "url", "", start, end, true)); + } + + [Theory] + [InlineData("api/{controller=home}/{action=index}/{*url:int:min(1)}", 37, 54)] + [InlineData("api/{controller=home}/{action=index}/{**url:int:min(1)}", 37, 55)] + public void ParseRoute_WhenRouteHasCatchAllParameterWithRouteConstraint_ReturnsRouteSegments(string httpRoute, int start, int end) + { + HttpRouteParser httpParser = CreateHttpRouteParser(); + + ParsedRouteSegments routeSegments = httpParser.ParseRoute(httpRoute); + + Assert.Equal(6, routeSegments.Segments.Length); + Assert.Equal(httpRoute, routeSegments.RouteTemplate); + + ValidateRouteSegment(routeSegments.Segments[0], ("api/", false, "", "", 0, 4, false)); + ValidateRouteSegment(routeSegments.Segments[1], ("controller=home", true, "controller", "home", 4, 21, false)); + ValidateRouteSegment(routeSegments.Segments[2], ("/", false, "", "", 21, 22, false)); + ValidateRouteSegment(routeSegments.Segments[3], ("action=index", true, "action", "index", 22, 36, false)); + ValidateRouteSegment(routeSegments.Segments[4], ("/", false, "", "", 36, 37, false)); + ValidateRouteSegment(routeSegments.Segments[5], ("url:int:min(1)", true, "url", "", start, end, true)); + } + + [Theory] + [InlineData("api/{controller=home}/{action=index}/{*url:regex(^(web|shared*)$)}", 37, 66)] + [InlineData("api/{controller=home}/{action=index}/{**url:regex(^(web|shared*)$)}", 37, 67)] + public void ParseRoute_WhenRouteHasCatchAllParameterWithRouteConstraintContainingRegexWithStar_ReturnsRouteSegments(string httpRoute, int start, int end) + { + HttpRouteParser httpParser = CreateHttpRouteParser(); + + ParsedRouteSegments routeSegments = httpParser.ParseRoute(httpRoute); + + Assert.Equal(6, routeSegments.Segments.Length); + Assert.Equal(httpRoute, routeSegments.RouteTemplate); + + ValidateRouteSegment(routeSegments.Segments[0], ("api/", false, "", "", 0, 4, false)); + ValidateRouteSegment(routeSegments.Segments[1], ("controller=home", true, "controller", "home", 4, 21, false)); + ValidateRouteSegment(routeSegments.Segments[2], ("/", false, "", "", 21, 22, false)); + ValidateRouteSegment(routeSegments.Segments[3], ("action=index", true, "action", "index", 22, 36, false)); + ValidateRouteSegment(routeSegments.Segments[4], ("/", false, "", "", 36, 37, false)); + ValidateRouteSegment(routeSegments.Segments[5], ("url:regex(^(web|shared*)$)", true, "url", "", start, end, true)); } [Fact] @@ -488,13 +666,16 @@ private static void ValidateRouteParameter( } private static void ValidateRouteSegment( - Segment segment, string content, bool isParam, string paramName, string defaultValue, int start, int end) + Segment segment, (string content, bool isParam, string paramName, string defaultValue, int start, int end, bool isCatchAll) values) { + var (content, isParam, paramName, defaultValue, start, end, isCatchAll) = values; + Assert.Equal(content, segment.Content); Assert.Equal(isParam, segment.IsParam); Assert.Equal(paramName, segment.ParamName); Assert.Equal(defaultValue, segment.DefaultValue); Assert.Equal(start, segment.Start); Assert.Equal(end, segment.End); + Assert.Equal(isCatchAll, segment.IsCatchAll); } } From 8b320e2f70b0edbe38f72a7a8f5d209fca0b0374 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 7 Nov 2024 14:04:04 -0500 Subject: [PATCH 099/190] Rework UseChatOptions as ConfigureOptions (#5606) * Update README to include a section on UseChatOptions * Rework UseChat/EmbeddingGenerationOptions to always clone The callbacks now configure the supplied instance. --- .../README.md | 16 +++++ .../ConfigureOptionsChatClient.cs | 63 ++++++++----------- ...igureOptionsChatClientBuilderExtensions.cs | 40 ++++-------- .../ConfigureOptionsEmbeddingGenerator.cs | 62 +++++++----------- ...ionsEmbeddingGeneratorBuilderExtensions.cs | 41 ++++-------- .../ChatClientIntegrationTests.cs | 6 +- .../ConfigureOptionsChatClientTests.cs | 29 ++++++--- ...ConfigureOptionsEmbeddingGeneratorTests.cs | 29 ++++++--- 8 files changed, 131 insertions(+), 155 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md index 9cbe166233a..7e8b369d80b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md @@ -212,6 +212,22 @@ IChatClient client = new ChatClientBuilder() Console.WriteLine((await client.CompleteAsync("What is AI?")).Message); ``` +#### Options + +Every call to `CompleteAsync` or `CompleteStreamingAsync` may optionally supply a `ChatOptions` instance containing additional parameters for the operation. The most common parameters that are common amongst AI models and services show up as strongly-typed properties on the type, such as `ChatOptions.Temperature`. Other parameters may be supplied by name in a weakly-typed manner via the `ChatOptions.AdditionalProperties` dictionary. + +Options may also be baked into an `IChatClient` via the `ConfigureOptions` extension method on `ChatClientBuilder`. This delegating client wraps another client and invokes the supplied delegate to populate a `ChatOptions` instance for every call. For example, to ensure that the `ChatOptions.ModelId` property defaults to a particular model name, code like the following may be used: +```csharp +using Microsoft.Extensions.AI; + +IChatClient client = new ChatClientBuilder() + .ConfigureOptions(options => options.ModelId ??= "phi3") + .Use(new OllamaChatClient(new Uri("http://localhost:11434"))); + +Console.WriteLine(await client.CompleteAsync("What is AI?")); // will request "phi3" +Console.WriteLine(await client.CompleteAsync("What is AI?", new() { ModelId = "llama3.1" })); // will request "llama3.1" +``` + #### Pipelines of Functionality All of these `IChatClient`s may be layered, creating a pipeline of any number of components that all add additional functionality. Such components may come from `Microsoft.Extensions.AI`, may come from other NuGet packages, or may be your own custom implementations that augment the behavior in whatever ways you need. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs index 990c92d3ad9..cdcbb283f12 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs @@ -8,67 +8,54 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable SA1629 // Documentation text should end with a period - namespace Microsoft.Extensions.AI; -/// A delegating chat client that updates or replaces the used by the remainder of the pipeline. -/// -/// -/// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options -/// with a new instance, the callback may simply return that new instance, for example _ => new ChatOptions() { MaxTokens = 1000 }. To provide -/// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example -/// options => options ?? new ChatOptions() { MaxTokens = 1000 }. Any changes to the caller-provided options instance will persist on the -/// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance -/// and mutating the clone, for example: -/// -/// options => -/// { -/// var newOptions = options?.Clone() ?? new(); -/// newOptions.MaxTokens = 1000; -/// return newOptions; -/// } -/// -/// -/// -/// The callback may return , in which case a options will be passed to the next client in the pipeline. -/// -/// -/// The provided implementation of is thread-safe for concurrent use so long as the employed configuration -/// callback is also thread-safe for concurrent requests. If callers employ a shared options instance, care should be taken in the -/// configuration callback, as multiple calls to it may end up running in parallel with the same options instance. -/// -/// +/// A delegating chat client that configures a instance used by the remainder of the pipeline. public sealed class ConfigureOptionsChatClient : DelegatingChatClient { /// The callback delegate used to configure options. - private readonly Func _configureOptions; + private readonly Action _configureOptions; - /// Initializes a new instance of the class with the specified callback. + /// Initializes a new instance of the class with the specified callback. /// The inner client. - /// - /// The delegate to invoke to configure the instance. It is passed the caller-supplied - /// instance and should return the configured instance to use. + /// + /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied instance + /// (or a newly-constructed instance if the caller-supplied instance is ). /// - public ConfigureOptionsChatClient(IChatClient innerClient, Func configureOptions) + /// + /// The delegate is passed either a new instance of if + /// the caller didn't supply a instance, or a clone (via of the caller-supplied + /// instance if one was supplied. + /// + public ConfigureOptionsChatClient(IChatClient innerClient, Action configure) : base(innerClient) { - _configureOptions = Throw.IfNull(configureOptions); + _configureOptions = Throw.IfNull(configure); } /// public override async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) { - return await base.CompleteAsync(chatMessages, _configureOptions(options), cancellationToken).ConfigureAwait(false); + return await base.CompleteAsync(chatMessages, Configure(options), cancellationToken).ConfigureAwait(false); } /// public override async IAsyncEnumerable CompleteStreamingAsync( IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - await foreach (var update in base.CompleteStreamingAsync(chatMessages, _configureOptions(options), cancellationToken).ConfigureAwait(false)) + await foreach (var update in base.CompleteStreamingAsync(chatMessages, Configure(options), cancellationToken).ConfigureAwait(false)) { yield return update; } } + + /// Creates and configures the to pass along to the inner client. + private ChatOptions Configure(ChatOptions? options) + { + options = options?.Clone() ?? new(); + + _configureOptions(options); + + return options; + } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs index 2d98fbd9003..c0ad600440b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs @@ -12,41 +12,25 @@ namespace Microsoft.Extensions.AI; public static class ConfigureOptionsChatClientBuilderExtensions { /// - /// Adds a callback that updates or replaces . This can be used to set default options. + /// Adds a callback that configures a to be passed to the next client in the pipeline. /// /// The . - /// - /// The delegate to invoke to configure the instance. It is passed the caller-supplied - /// instance and should return the configured instance to use. + /// + /// The delegate to invoke to configure the instance. + /// It is passed a clone of the caller-supplied instance (or a newly-constructed instance if the caller-supplied instance is ). /// - /// The . /// - /// - /// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options - /// with a new instance, the callback may simply return that new instance, for example _ => new ChatOptions() { MaxTokens = 1000 }. To provide - /// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example - /// options => options ?? new ChatOptions() { MaxTokens = 1000 }. Any changes to the caller-provided options instance will persist on the - /// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance - /// and mutating the clone, for example: - /// - /// options => - /// { - /// var newOptions = options?.Clone() ?? new(); - /// newOptions.MaxTokens = 1000; - /// return newOptions; - /// } - /// - /// - /// - /// The callback may return , in which case a options will be passed to the next client in the pipeline. - /// + /// This can be used to set default options. The delegate is passed either a new instance of + /// if the caller didn't supply a instance, or a clone (via + /// of the caller-supplied instance if one was supplied. /// - public static ChatClientBuilder UseChatOptions( - this ChatClientBuilder builder, Func configureOptions) + /// The . + public static ChatClientBuilder ConfigureOptions( + this ChatClientBuilder builder, Action configure) { _ = Throw.IfNull(builder); - _ = Throw.IfNull(configureOptions); + _ = Throw.IfNull(configure); - return builder.Use(innerClient => new ConfigureOptionsChatClient(innerClient, configureOptions)); + return builder.Use(innerClient => new ConfigureOptionsChatClient(innerClient, configure)); } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs index 9068ac41caa..d4125ef9aa0 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs @@ -3,65 +3,41 @@ using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable SA1629 // Documentation text should end with a period - namespace Microsoft.Extensions.AI; -/// A delegating embedding generator that updates or replaces the used by the remainder of the pipeline. +/// A delegating embedding generator that configures a instance used by the remainder of the pipeline. /// Specifies the type of the input passed to the generator. /// Specifies the type of the embedding instance produced by the generator. -/// -/// -/// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options -/// with a new instance, the callback may simply return that new instance, for example _ => new EmbeddingGenerationOptions() { Dimensions = 100 }. To provide -/// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example -/// options => options ?? new EmbeddingGenerationOptions() { Dimensions = 100 }. Any changes to the caller-provided options instance will persist on the -/// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance -/// and mutating the clone, for example: -/// -/// options => -/// { -/// var newOptions = options?.Clone() ?? new(); -/// newOptions.Dimensions = 100; -/// return newOptions; -/// } -/// -/// -/// -/// The callback may return , in which case a options will be passed to the next generator in the pipeline. -/// -/// -/// The provided implementation of is thread-safe for concurrent use so long as the employed configuration -/// callback is also thread-safe for concurrent requests. If callers employ a shared options instance, care should be taken in the -/// configuration callback, as multiple calls to it may end up running in parallel with the same options instance. -/// -/// public sealed class ConfigureOptionsEmbeddingGenerator : DelegatingEmbeddingGenerator where TEmbedding : Embedding { /// The callback delegate used to configure options. - private readonly Func _configureOptions; + private readonly Action _configureOptions; /// /// Initializes a new instance of the class with the - /// specified callback. + /// specified callback. /// /// The inner generator. - /// - /// The delegate to invoke to configure the instance. It is passed the caller-supplied - /// instance and should return the configured instance to use. + /// + /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied + /// instance (or a newly-constructed instance if the caller-supplied instance is ). /// + /// + /// The delegate is passed either a new instance of if + /// the caller didn't supply a instance, or a clone (via of the caller-supplied + /// instance if one was supplied. + /// public ConfigureOptionsEmbeddingGenerator( IEmbeddingGenerator innerGenerator, - Func configureOptions) + Action configure) : base(innerGenerator) { - _configureOptions = Throw.IfNull(configureOptions); + _configureOptions = Throw.IfNull(configure); } /// @@ -70,6 +46,16 @@ public override async Task> GenerateAsync( EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) { - return await base.GenerateAsync(values, _configureOptions(options), cancellationToken).ConfigureAwait(false); + return await base.GenerateAsync(values, Configure(options), cancellationToken).ConfigureAwait(false); + } + + /// Creates and configures the to pass along to the inner client. + private EmbeddingGenerationOptions Configure(EmbeddingGenerationOptions? options) + { + options = options?.Clone() ?? new(); + + _configureOptions(options); + + return options; } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs index 011f4c058e9..be469786247 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs @@ -12,45 +12,30 @@ namespace Microsoft.Extensions.AI; public static class ConfigureOptionsEmbeddingGeneratorBuilderExtensions { /// - /// Adds a callback that updates or replaces . This can be used to set default options. + /// Adds a callback that configures a to be passed to the next client in the pipeline. /// /// Specifies the type of the input passed to the generator. /// Specifies the type of the embedding instance produced by the generator. /// The . - /// - /// The delegate to invoke to configure the instance. It is passed the caller-supplied - /// instance and should return the configured instance to use. + /// + /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied + /// instance (or a newly-constructed instance if the caller-supplied instance is ). /// - /// The . /// - /// - /// The configuration callback is invoked with the caller-supplied instance. To override the caller-supplied options - /// with a new instance, the callback may simply return that new instance, for example _ => new EmbeddingGenerationOptions() { Dimensions = 100 }. To provide - /// a new instance only if the caller-supplied instance is , the callback may conditionally return a new instance, for example - /// options => options ?? new EmbeddingGenerationOptions() { Dimensions = 100 }. Any changes to the caller-provided options instance will persist on the - /// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance - /// and mutating the clone, for example: - /// - /// options => - /// { - /// var newOptions = options?.Clone() ?? new(); - /// newOptions.Dimensions = 100; - /// return newOptions; - /// } - /// - /// - /// - /// The callback may return , in which case a options will be passed to the next generator in the pipeline. - /// + /// This can be used to set default options. The delegate is passed either a new instance of + /// if the caller didn't supply a instance, or + /// a clone (via + /// of the caller-supplied instance if one was supplied. /// - public static EmbeddingGeneratorBuilder UseEmbeddingGenerationOptions( + /// The . + public static EmbeddingGeneratorBuilder ConfigureOptions( this EmbeddingGeneratorBuilder builder, - Func configureOptions) + Action configure) where TEmbedding : Embedding { _ = Throw.IfNull(builder); - _ = Throw.IfNull(configureOptions); + _ = Throw.IfNull(configure); - return builder.Use(innerGenerator => new ConfigureOptionsEmbeddingGenerator(innerGenerator, configureOptions)); + return builder.Use(innerGenerator => new ConfigureOptionsEmbeddingGenerator(innerGenerator, configure)); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index e9c2bd57d65..ce376e3927d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -378,7 +378,7 @@ public virtual async Task Caching_BeforeFunctionInvocation_AvoidsExtraCalls() // First call executes the function and calls the LLM using var chatClient = new ChatClientBuilder() - .UseChatOptions(_ => new() { Tools = [getTemperature] }) + .ConfigureOptions(options => options.Tools = [getTemperature]) .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .UseFunctionInvocation() .UseCallCounting() @@ -416,7 +416,7 @@ public virtual async Task Caching_AfterFunctionInvocation_FunctionOutputUnchange // First call executes the function and calls the LLM using var chatClient = new ChatClientBuilder() - .UseChatOptions(_ => new() { Tools = [getTemperature] }) + .ConfigureOptions(options => options.Tools = [getTemperature]) .UseFunctionInvocation() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .UseCallCounting() @@ -455,7 +455,7 @@ public virtual async Task Caching_AfterFunctionInvocation_FunctionOutputChangedA // First call executes the function and calls the LLM using var chatClient = new ChatClientBuilder() - .UseChatOptions(_ => new() { Tools = [getTemperature] }) + .ConfigureOptions(options => options.Tools = [getTemperature]) .UseFunctionInvocation() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .UseCallCounting() diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs index a911340813f..6b1e6587f1f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs @@ -15,24 +15,24 @@ public class ConfigureOptionsChatClientTests [Fact] public void ConfigureOptionsChatClient_InvalidArgs_Throws() { - Assert.Throws("innerClient", () => new ConfigureOptionsChatClient(null!, _ => new ChatOptions())); - Assert.Throws("configureOptions", () => new ConfigureOptionsChatClient(new TestChatClient(), null!)); + Assert.Throws("innerClient", () => new ConfigureOptionsChatClient(null!, _ => { })); + Assert.Throws("configure", () => new ConfigureOptionsChatClient(new TestChatClient(), null!)); } [Fact] - public void UseChatOptions_InvalidArgs_Throws() + public void ConfigureOptions_InvalidArgs_Throws() { var builder = new ChatClientBuilder(); - Assert.Throws("configureOptions", () => builder.UseChatOptions(null!)); + Assert.Throws("configure", () => builder.ConfigureOptions(null!)); } [Theory] [InlineData(false)] [InlineData(true)] - public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullReturned) + public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullProvidedOptions) { - ChatOptions providedOptions = new(); - ChatOptions? returnedOptions = nullReturned ? null : new(); + ChatOptions? providedOptions = nullProvidedOptions ? null : new() { ModelId = "test" }; + ChatOptions? returnedOptions = null; ChatCompletion expectedCompletion = new(Array.Empty()); var expectedUpdates = Enumerable.Range(0, 3).Select(i => new StreamingChatCompletionUpdate()).ToArray(); using CancellationTokenSource cts = new(); @@ -55,10 +55,19 @@ public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullR }; using var client = new ChatClientBuilder() - .UseChatOptions(options => + .ConfigureOptions(options => { - Assert.Same(providedOptions, options); - return returnedOptions; + Assert.NotSame(providedOptions, options); + if (nullProvidedOptions) + { + Assert.Null(options.ModelId); + } + else + { + Assert.Equal(providedOptions!.ModelId, options.ModelId); + } + + returnedOptions = options; }) .Use(innerClient); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs index b8a4b82cb59..70674646bd1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs @@ -13,24 +13,24 @@ public class ConfigureOptionsEmbeddingGeneratorTests [Fact] public void ConfigureOptionsEmbeddingGenerator_InvalidArgs_Throws() { - Assert.Throws("innerGenerator", () => new ConfigureOptionsEmbeddingGenerator>(null!, _ => new EmbeddingGenerationOptions())); - Assert.Throws("configureOptions", () => new ConfigureOptionsEmbeddingGenerator>(new TestEmbeddingGenerator(), null!)); + Assert.Throws("innerGenerator", () => new ConfigureOptionsEmbeddingGenerator>(null!, _ => { })); + Assert.Throws("configure", () => new ConfigureOptionsEmbeddingGenerator>(new TestEmbeddingGenerator(), null!)); } [Fact] - public void UseEmbeddingGenerationOptions_InvalidArgs_Throws() + public void ConfigureOptions_InvalidArgs_Throws() { var builder = new EmbeddingGeneratorBuilder>(); - Assert.Throws("configureOptions", () => builder.UseEmbeddingGenerationOptions(null!)); + Assert.Throws("configure", () => builder.ConfigureOptions(null!)); } [Theory] [InlineData(false)] [InlineData(true)] - public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullReturned) + public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullProvidedOptions) { - EmbeddingGenerationOptions providedOptions = new(); - EmbeddingGenerationOptions? returnedOptions = nullReturned ? null : new(); + EmbeddingGenerationOptions? providedOptions = nullProvidedOptions ? null : new() { ModelId = "test" }; + EmbeddingGenerationOptions? returnedOptions = null; GeneratedEmbeddings> expectedEmbeddings = []; using CancellationTokenSource cts = new(); @@ -45,10 +45,19 @@ public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullR }; using var generator = new EmbeddingGeneratorBuilder>() - .UseEmbeddingGenerationOptions(options => + .ConfigureOptions(options => { - Assert.Same(providedOptions, options); - return returnedOptions; + Assert.NotSame(providedOptions, options); + if (nullProvidedOptions) + { + Assert.Null(options.ModelId); + } + else + { + Assert.Equal(providedOptions!.ModelId, options.ModelId); + } + + returnedOptions = options; }) .Use(innerGenerator); From aba6fd63b4f90ba0f0125e4d7ec5caf2d19cd2f3 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 7 Nov 2024 14:08:34 -0500 Subject: [PATCH 100/190] Make IChatClient/IEmbeddingGenerator.GetService non-generic (#5608) --- .../ChatCompletion/ChatClientExtensions.cs | 16 ++++++++ .../ChatCompletion/DelegatingChatClient.cs | 13 ++++--- .../ChatCompletion/IChatClient.cs | 9 ++--- .../DelegatingEmbeddingGenerator.cs | 13 ++++--- .../EmbeddingGeneratorExtensions.cs | 37 +++++++++++++++++++ .../Embeddings/IEmbeddingGenerator.cs | 13 +++---- .../AzureAIInferenceChatClient.cs | 14 +++++-- .../AzureAIInferenceEmbeddingGenerator.cs | 14 +++++-- .../OllamaChatClient.cs | 11 ++++-- .../OllamaEmbeddingGenerator.cs | 11 ++++-- .../OpenAIChatClient.cs | 17 ++++++--- .../OpenAIEmbeddingGenerator.cs | 18 ++++++--- .../ChatClientExtensionsTests.cs | 6 +++ .../DelegatingChatClientTests.cs | 8 ++++ .../DelegatingEmbeddingGeneratorTests.cs | 8 ++++ .../EmbeddingGeneratorExtensionsTests.cs | 7 ++++ .../TestChatClient.cs | 5 +-- .../TestEmbeddingGenerator.cs | 5 +-- .../QuantizationEmbeddingGenerator.cs | 7 ++-- 19 files changed, 173 insertions(+), 59 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs index 944283ccd88..9e2019d9e52 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs @@ -11,6 +11,22 @@ namespace Microsoft.Extensions.AI; /// Provides a collection of static methods for extending instances. public static class ChatClientExtensions { + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// The client. + /// An optional key that may be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService? GetService(this IChatClient client, object? serviceKey = null) + { + _ = Throw.IfNull(client); + + return (TService?)client.GetService(typeof(TService), serviceKey); + } + /// Sends a user chat text message to the model and returns the response messages. /// The chat client. /// The text content for the chat message to send. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs index a6fb40b3555..d92590bad92 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs @@ -63,12 +63,13 @@ public virtual IAsyncEnumerable CompleteStreaming } /// - public virtual TService? GetService(object? key = null) - where TService : class + public virtual object? GetService(Type serviceType, object? serviceKey = null) { -#pragma warning disable S3060 // "is" should not be used with "this" - // If the key is non-null, we don't know what it means so pass through to the inner service - return key is null && this is TService service ? service : InnerClient.GetService(key); -#pragma warning restore S3060 + _ = Throw.IfNull(serviceType); + + // If the key is non-null, we don't know what it means so pass through to the inner service. + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerClient.GetService(serviceType, serviceKey); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs index 8cbfa1314f4..4e3fd126b37 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs @@ -56,14 +56,13 @@ IAsyncEnumerable CompleteStreamingAsync( /// Gets metadata that describes the . ChatClientMetadata Metadata { get; } - /// Asks the for an object of type . - /// The type of the object to be retrieved. - /// An optional key that may be used to help identify the target service. + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that may be used to help identify the target service. /// The found object, otherwise . /// /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the , /// including itself or any services it might be wrapping. /// - TService? GetService(object? key = null) - where TService : class; + object? GetService(Type serviceType, object? serviceKey = null); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs index 6b06d32d6d7..590817d4e11 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs @@ -59,12 +59,13 @@ public virtual Task> GenerateAsync(IEnumerable - public virtual TService? GetService(object? key = null) - where TService : class + public virtual object? GetService(Type serviceType, object? serviceKey = null) { -#pragma warning disable S3060 // "is" should not be used with "this" - // If the key is non-null, we don't know what it means so pass through to the inner service - return key is null && this is TService service ? service : InnerGenerator.GetService(key); -#pragma warning restore S3060 + _ = Throw.IfNull(serviceType); + + // If the key is non-null, we don't know what it means so pass through to the inner service. + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerGenerator.GetService(serviceType, serviceKey); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs index efa804fd0eb..8a388d361b9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs @@ -15,6 +15,43 @@ namespace Microsoft.Extensions.AI; /// Provides a collection of static methods for extending instances. public static class EmbeddingGeneratorExtensions { + /// Asks the for an object of type . + /// The type from which embeddings will be generated. + /// The numeric type of the embedding data. + /// The type of the object to be retrieved. + /// The generator. + /// An optional key that may be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the + /// , including itself or any services it might be wrapping. + /// + public static TService? GetService(this IEmbeddingGenerator generator, object? serviceKey = null) + where TEmbedding : Embedding + { + _ = Throw.IfNull(generator); + + return (TService?)generator.GetService(typeof(TService), serviceKey); + } + + // The following overload exists purely to work around the lack of partial generic type inference. + // Given an IEmbeddingGenerator generator, to call GetService with TService, you still need + // to re-specify both TInput and TEmbedding, e.g. generator.GetService, TService>. + // The case of string/Embedding is by far the most common case today, so this overload exists as an + // accelerator to allow it to be written simply as generator.GetService. + + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// The generator. + /// An optional key that may be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the + /// , including itself or any services it might be wrapping. + /// + public static TService? GetService(this IEmbeddingGenerator> generator, object? serviceKey = null) => + GetService, TService>(generator, serviceKey); + /// Generates an embedding vector from the specified . /// The type from which embeddings will be generated. /// The numeric type of the embedding data. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs index 5cc289fbb5e..9f9c9f1325f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs @@ -40,14 +40,13 @@ Task> GenerateAsync( /// Gets metadata that describes the . EmbeddingGeneratorMetadata Metadata { get; } - /// Asks the for an object of type . - /// The type of the object to be retrieved. - /// An optional key that may be used to help identify the target service. + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that may be used to help identify the target service. /// The found object, otherwise . /// - /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the , - /// including itself or any services it might be wrapping. + /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the + /// , including itself or any services it might be wrapping. /// - TService? GetService(object? key = null) - where TService : class; + object? GetService(Type serviceType, object? serviceKey = null); } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index ba76f5c3c90..143d5928106 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -57,10 +57,16 @@ public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, s public ChatClientMetadata Metadata { get; } /// - public TService? GetService(object? key = null) - where TService : class => - typeof(TService) == typeof(ChatCompletionsClient) ? (TService?)(object?)_chatCompletionsClient : - this as TService; + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is not null ? null : + serviceType == typeof(ChatCompletionsClient) ? _chatCompletionsClient : + serviceType.IsInstanceOfType(this) ? this : + null; + } /// public async Task CompleteAsync( diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs index 866e55ad87a..3f8f2adb3ff 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs @@ -70,10 +70,16 @@ public AzureAIInferenceEmbeddingGenerator( public EmbeddingGeneratorMetadata Metadata { get; } /// - public TService? GetService(object? key = null) - where TService : class => - typeof(TService) == typeof(EmbeddingsClient) ? (TService)(object)_embeddingsClient : - this as TService; + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is not null ? null : + serviceType == typeof(EmbeddingsClient) ? _embeddingsClient : + serviceType.IsInstanceOfType(this) ? this : + null; + } /// public async Task>> GenerateAsync( diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 18ff5d50b7c..e6084e94ab6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -166,9 +166,14 @@ public async IAsyncEnumerable CompleteStreamingAs } /// - public TService? GetService(object? key = null) - where TService : class - => key is null ? this as TService : null; + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + null; + } /// public void Dispose() diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs index 5779b60cbc0..ea273c31b4c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs @@ -57,9 +57,14 @@ public OllamaEmbeddingGenerator(Uri endpoint, string? modelId = null, HttpClient public EmbeddingGeneratorMetadata Metadata { get; } /// - public TService? GetService(object? key = null) - where TService : class - => key is null ? this as TService : null; + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + null; + } /// public void Dispose() diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 985060256f7..5490466b66a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -16,6 +16,7 @@ using OpenAI; using OpenAI.Chat; +#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S1135 // Track uses of "TODO" tags #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1204 // Static elements should appear before instance elements @@ -85,11 +86,17 @@ public OpenAIChatClient(ChatClient chatClient) public ChatClientMetadata Metadata { get; } /// - public TService? GetService(object? key = null) - where TService : class => - typeof(TService) == typeof(OpenAIClient) ? (TService?)(object?)_openAIClient : - typeof(TService) == typeof(ChatClient) ? (TService)(object)_chatClient : - this as TService; + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is not null ? null : + serviceType == typeof(OpenAIClient) ? _openAIClient : + serviceType == typeof(ChatClient) ? _chatClient : + serviceType.IsInstanceOfType(this) ? this : + null; + } /// public async Task CompleteAsync( diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index 155e047279f..5c34a8028a2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -11,6 +11,7 @@ using OpenAI; using OpenAI.Embeddings; +#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 namespace Microsoft.Extensions.AI; @@ -95,12 +96,17 @@ private static EmbeddingGeneratorMetadata CreateMetadata(string providerName, st public EmbeddingGeneratorMetadata Metadata { get; } /// - public TService? GetService(object? key = null) - where TService : class - => - typeof(TService) == typeof(OpenAIClient) ? (TService?)(object?)_openAIClient : - typeof(TService) == typeof(EmbeddingClient) ? (TService)(object)_embeddingClient : - this as TService; + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is not null ? null : + serviceType == typeof(OpenAIClient) ? _openAIClient : + serviceType == typeof(EmbeddingClient) ? _embeddingClient : + serviceType.IsInstanceOfType(this) ? this : + null; + } /// public async Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs index 68f5ad12245..3732e80503f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs @@ -11,6 +11,12 @@ namespace Microsoft.Extensions.AI; public class ChatClientExtensionsTests { + [Fact] + public void GetService_InvalidArgs_Throws() + { + Assert.Throws("client", () => ChatClientExtensions.GetService(null!)); + } + [Fact] public void CompleteAsync_InvalidArgs_Throws() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs index 51c82c7dcb7..35027bb71f9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs @@ -96,6 +96,14 @@ public async Task ChatStreamingAsyncDefaultsToInnerClientAsync() Assert.False(await enumerator.MoveNextAsync()); } + [Fact] + public void GetServiceThrowsForNullType() + { + using var inner = new TestChatClient(); + using var delegating = new NoOpDelegatingChatClient(inner); + Assert.Throws("serviceType", () => delegating.GetService(null!)); + } + [Fact] public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs index 91640e62f4f..3f6732a410d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs @@ -57,6 +57,14 @@ public async Task GenerateEmbeddingsDefaultsToInnerServiceAsync() Assert.Same(expectedEmbedding, await resultTask); } + [Fact] + public void GetServiceThrowsForNullType() + { + using var inner = new TestEmbeddingGenerator(); + using var delegating = new NoOpDelegatingEmbeddingGenerator(inner); + Assert.Throws("serviceType", () => delegating.GetService(null!)); + } + [Fact] public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs index b6deb1ccd0f..4466dd85d1e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs @@ -10,6 +10,13 @@ namespace Microsoft.Extensions.AI; public class EmbeddingGeneratorExtensionsTests { + [Fact] + public void GetService_InvalidArgs_Throws() + { + Assert.Throws("generator", () => EmbeddingGeneratorExtensions.GetService(null!)); + Assert.Throws("generator", () => EmbeddingGeneratorExtensions.GetService, object>(null!)); + } + [Fact] public async Task GenerateAsync_InvalidArgs_ThrowsAsync() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs index 55f4c486483..5eacced35b7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs @@ -26,9 +26,8 @@ public Task CompleteAsync(IList chatMessages, ChatO public IAsyncEnumerable CompleteStreamingAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) => CompleteStreamingAsyncCallback!.Invoke(chatMessages, options, cancellationToken); - public TService? GetService(object? key = null) - where TService : class - => (TService?)GetServiceCallback!(typeof(TService), key); + public object? GetService(Type serviceType, object? serviceKey = null) + => GetServiceCallback!(serviceType, serviceKey); void IDisposable.Dispose() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs index 83680a2be10..5b79b1908da 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs @@ -19,9 +19,8 @@ public sealed class TestEmbeddingGenerator : IEmbeddingGenerator>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) => GenerateAsyncCallback!.Invoke(values, options, cancellationToken); - public TService? GetService(object? key = null) - where TService : class - => (TService?)GetServiceCallback!(typeof(TService), key); + public object? GetService(Type serviceType, object? serviceKey = null) + => GetServiceCallback!(serviceType, serviceKey); void IDisposable.Dispose() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs index 90032f16434..c48dc2e23e8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs @@ -29,10 +29,9 @@ public QuantizationEmbeddingGenerator(IEmbeddingGenerator _floatService.Dispose(); - public TService? GetService(object? key = null) - where TService : class => - key is null && this is TService ? (TService?)(object)this : - _floatService.GetService(key); + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + _floatService.GetService(serviceType, serviceKey); async Task> IEmbeddingGenerator.GenerateAsync( IEnumerable values, EmbeddingGenerationOptions? options, CancellationToken cancellationToken) From b74ff9cb861b8860d25c9352a5bbd235ca31ef29 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 7 Nov 2024 15:50:03 -0500 Subject: [PATCH 101/190] Add logging/activities to FunctionInvokingChatClient (#5596) * Add logging/activities to FunctionInvokingChatClient * Change FunctionInvokingChatClient to use ActivitySource from OpenTelemetryChatClient --- .../FunctionInvokingChatClient.cs | 104 +++++++++++++++++- ...tionInvokingChatClientBuilderExtensions.cs | 14 ++- .../ChatCompletion/LoggingChatClient.cs | 2 +- .../ChatCompletion/OpenTelemetryChatClient.cs | 9 +- .../OpenTelemetryEmbeddingGenerator.cs | 29 +++-- .../Microsoft.Extensions.AI/LoggingHelpers.cs | 34 ++++++ .../TestChatClient.cs | 4 +- .../TestEmbeddingGenerator.cs | 4 +- .../FunctionInvokingChatClientTests.cs | 94 ++++++++++++++++ 9 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI/LoggingHelpers.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 308480635d8..09846198802 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -8,8 +8,12 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; +#pragma warning disable CA2213 // Disposable fields should be disposed + namespace Microsoft.Extensions.AI; /// @@ -34,8 +38,15 @@ namespace Microsoft.Extensions.AI; /// invocation requests to that same function. /// /// -public class FunctionInvokingChatClient : DelegatingChatClient +public partial class FunctionInvokingChatClient : DelegatingChatClient { + /// The logger to use for logging information about function invocation. + private readonly ILogger _logger; + + /// The to use for telemetry. + /// This component does not own the instance and should not dispose it. + private readonly ActivitySource? _activitySource; + /// Maximum number of roundtrips allowed to the inner client. private int? _maximumIterationsPerRequest; @@ -43,9 +54,12 @@ public class FunctionInvokingChatClient : DelegatingChatClient /// Initializes a new instance of the class. /// /// The underlying , or the next instance in a chain of clients. - public FunctionInvokingChatClient(IChatClient innerClient) + /// An to use for logging information about function invocation. + public FunctionInvokingChatClient(IChatClient innerClient, ILogger? logger = null) : base(innerClient) { + _logger = logger ?? NullLogger.Instance; + _activitySource = innerClient.GetService(); } /// @@ -562,13 +576,95 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul /// /// The to monitor for cancellation requests. The default is . /// The result of the function invocation. This may be null if the function invocation returned null. - protected virtual Task InvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) + protected virtual async Task InvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) { _ = Throw.IfNull(context); - return context.Function.InvokeAsync(context.CallContent.Arguments, cancellationToken); + using Activity? activity = _activitySource?.StartActivity(context.Function.Metadata.Name); + + long startingTimestamp = 0; + if (_logger.IsEnabled(LogLevel.Debug)) + { + startingTimestamp = Stopwatch.GetTimestamp(); + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokingSensitive(context.Function.Metadata.Name, LoggingHelpers.AsJson(context.CallContent.Arguments, context.Function.Metadata.JsonSerializerOptions)); + } + else + { + LogInvoking(context.Function.Metadata.Name); + } + } + + object? result = null; + try + { + result = await context.Function.InvokeAsync(context.CallContent.Arguments, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + if (activity is not null) + { + _ = activity.SetTag("error.type", e.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, e.Message); + } + + if (e is OperationCanceledException) + { + LogInvocationCanceled(context.Function.Metadata.Name); + } + else + { + LogInvocationFailed(context.Function.Metadata.Name, e); + } + + throw; + } + finally + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + TimeSpan elapsed = GetElapsedTime(startingTimestamp); + + if (result is not null && _logger.IsEnabled(LogLevel.Trace)) + { + LogInvocationCompletedSensitive(context.Function.Metadata.Name, elapsed, LoggingHelpers.AsJson(result, context.Function.Metadata.JsonSerializerOptions)); + } + else + { + LogInvocationCompleted(context.Function.Metadata.Name, elapsed); + } + } + } + + return result; } + private static TimeSpan GetElapsedTime(long startingTimestamp) => +#if NET + Stopwatch.GetElapsedTime(startingTimestamp); +#else + new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); +#endif + + [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] + private partial void LogInvoking(string methodName); + + [LoggerMessage(LogLevel.Trace, "Invoking {MethodName}({Arguments}).", SkipEnabledCheck = true)] + private partial void LogInvokingSensitive(string methodName, string arguments); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invocation completed. Duration: {Duration}", SkipEnabledCheck = true)] + private partial void LogInvocationCompleted(string methodName, TimeSpan duration); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invocation completed. Duration: {Duration}. Result: {Result}", SkipEnabledCheck = true)] + private partial void LogInvocationCompletedSensitive(string methodName, TimeSpan duration, string result); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invocation canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} invocation failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); + /// Provides context for a function invocation. public sealed class FunctionInvocationContext { diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.cs index 15010b42068..fa64bcedc78 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -16,15 +18,21 @@ public static class FunctionInvokingChatClientBuilderExtensions /// /// This works by adding an instance of with default options. /// The being used to build the chat pipeline. + /// An optional to use to create a logger for logging function invocations. /// An optional callback that can be used to configure the instance. /// The supplied . - public static ChatClientBuilder UseFunctionInvocation(this ChatClientBuilder builder, Action? configure = null) + public static ChatClientBuilder UseFunctionInvocation( + this ChatClientBuilder builder, + ILoggerFactory? loggerFactory = null, + Action? configure = null) { _ = Throw.IfNull(builder); - return builder.Use(innerClient => + return builder.Use((services, innerClient) => { - var chatClient = new FunctionInvokingChatClient(innerClient); + loggerFactory ??= services.GetService(); + + var chatClient = new FunctionInvokingChatClient(innerClient, loggerFactory?.CreateLogger(typeof(FunctionInvokingChatClient))); configure?.Invoke(chatClient); return chatClient; }); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs index fc01b8c21b9..b816af150b7 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs @@ -168,7 +168,7 @@ public override async IAsyncEnumerable CompleteSt } } - private string AsJson(T value) => JsonSerializer.Serialize(value, _jsonSerializerOptions.GetTypeInfo(typeof(T))); + private string AsJson(T value) => LoggingHelpers.AsJson(value, _jsonSerializerOptions); [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] private partial void LogInvoked(string methodName); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index a6dfe53adf5..6274c39419b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -17,6 +17,8 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; +#pragma warning disable S3358 // Ternary operators should not be nested + namespace Microsoft.Extensions.AI; /// A delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. @@ -106,6 +108,11 @@ protected override void Dispose(bool disposing) /// public bool EnableSensitiveData { get; set; } + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ActivitySource) ? _activitySource : + base.GetService(serviceType, serviceKey); + /// public override async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) { @@ -254,7 +261,7 @@ private static ChatCompletion ComposeStreamingUpdatesIntoChatCompletion( string? modelId = options?.ModelId ?? _modelId; activity = _activitySource.StartActivity( - $"{OpenTelemetryConsts.GenAI.Chat} {modelId}", + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Chat : $"{OpenTelemetryConsts.GenAI.Chat} {modelId}", ActivityKind.Client); if (activity is not null) diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs index c085aaef350..2dce06620a8 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -72,13 +72,19 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator i advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries }); } + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ActivitySource) ? _activitySource : + base.GetService(serviceType, serviceKey); + /// public override async Task> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(values); - using Activity? activity = CreateAndConfigureActivity(); + using Activity? activity = CreateAndConfigureActivity(options); Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _modelId; GeneratedEmbeddings? response = null; Exception? error = null; @@ -93,7 +99,7 @@ public override async Task> GenerateAsync(IEnume } finally { - TraceCompletion(activity, response, error, stopwatch); + TraceCompletion(activity, requestModelId, response, error, stopwatch); } return response; @@ -112,18 +118,20 @@ protected override void Dispose(bool disposing) } /// Creates an activity for an embedding generation request, or returns null if not enabled. - private Activity? CreateAndConfigureActivity() + private Activity? CreateAndConfigureActivity(EmbeddingGenerationOptions? options) { Activity? activity = null; if (_activitySource.HasListeners()) { + string? modelId = options?.ModelId ?? _modelId; + activity = _activitySource.StartActivity( - $"{OpenTelemetryConsts.GenAI.Embed} {_modelId}", + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Embed : $"{OpenTelemetryConsts.GenAI.Embed} {modelId}", ActivityKind.Client, default(ActivityContext), [ new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embed), - new(OpenTelemetryConsts.GenAI.Request.Model, _modelId), + new(OpenTelemetryConsts.GenAI.Request.Model, modelId), new(OpenTelemetryConsts.GenAI.SystemName, _modelProvider), ]); @@ -149,6 +157,7 @@ protected override void Dispose(bool disposing) /// Adds embedding generation response information to the activity. private void TraceCompletion( Activity? activity, + string? requestModelId, GeneratedEmbeddings? embeddings, Exception? error, Stopwatch? stopwatch) @@ -167,7 +176,7 @@ private void TraceCompletion( if (_operationDurationHistogram.Enabled && stopwatch is not null) { TagList tags = default; - AddMetricTags(ref tags, responseModelId); + AddMetricTags(ref tags, requestModelId, responseModelId); if (error is not null) { tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); @@ -180,7 +189,7 @@ private void TraceCompletion( { TagList tags = default; tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "input"); - AddMetricTags(ref tags, responseModelId); + AddMetricTags(ref tags, requestModelId, responseModelId); _tokenUsageHistogram.Record(inputTokens.Value); } @@ -206,13 +215,13 @@ private void TraceCompletion( } } - private void AddMetricTags(ref TagList tags, string? responseModelId) + private void AddMetricTags(ref TagList tags, string? requestModelId, string? responseModelId) { tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embed); - if (_modelId is string requestModel) + if (requestModelId is not null) { - tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModel); + tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); } tags.Add(OpenTelemetryConsts.GenAI.SystemName, _modelProvider); diff --git a/src/Libraries/Microsoft.Extensions.AI/LoggingHelpers.cs b/src/Libraries/Microsoft.Extensions.AI/LoggingHelpers.cs new file mode 100644 index 00000000000..72a7e283988 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/LoggingHelpers.cs @@ -0,0 +1,34 @@ +// 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 CA1031 // Do not catch general exception types +#pragma warning disable S108 // Nested blocks of code should not be left empty +#pragma warning disable S2486 // Generic exceptions should not be ignored + +using System.Text.Json; + +namespace Microsoft.Extensions.AI; + +/// Provides internal helpers for implementing logging. +internal static class LoggingHelpers +{ + /// Serializes as JSON for logging purposes. + public static string AsJson(T value, JsonSerializerOptions? options) + { + if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true || + AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo)) + { + try + { + return JsonSerializer.Serialize(value, typeInfo); + } + catch + { + } + } + + // If we're unable to get a type info for the value, or if we fail to serialize, + // return an empty JSON object. We do not want lack of type info to disrupt application behavior with exceptions. + return "{}"; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs index 5eacced35b7..64a632d0846 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs @@ -18,7 +18,7 @@ public sealed class TestChatClient : IChatClient public Func, ChatOptions?, CancellationToken, IAsyncEnumerable>? CompleteStreamingAsyncCallback { get; set; } - public Func? GetServiceCallback { get; set; } + public Func GetServiceCallback { get; set; } = (_, _) => null; public Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) => CompleteAsyncCallback!.Invoke(chatMessages, options, cancellationToken); @@ -27,7 +27,7 @@ public IAsyncEnumerable CompleteStreamingAsync(IL => CompleteStreamingAsyncCallback!.Invoke(chatMessages, options, cancellationToken); public object? GetService(Type serviceType, object? serviceKey = null) - => GetServiceCallback!(serviceType, serviceKey); + => GetServiceCallback(serviceType, serviceKey); void IDisposable.Dispose() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs index 5b79b1908da..7438edc752e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs @@ -14,13 +14,13 @@ public sealed class TestEmbeddingGenerator : IEmbeddingGenerator, EmbeddingGenerationOptions?, CancellationToken, Task>>>? GenerateAsyncCallback { get; set; } - public Func? GetServiceCallback { get; set; } + public Func GetServiceCallback { get; set; } = (_, _) => null; public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) => GenerateAsyncCallback!.Invoke(values, options, cancellationToken); public object? GetService(Type serviceType, object? serviceKey = null) - => GetServiceCallback!(serviceType, serviceKey); + => GetServiceCallback(serviceType, serviceKey); void IDisposable.Dispose() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 20780d968f7..542851baa69 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -3,15 +3,26 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Trace; using Xunit; namespace Microsoft.Extensions.AI; public class FunctionInvokingChatClientTests { + [Fact] + public void InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new FunctionInvokingChatClient(null!)); + Assert.Throws("builder", () => ((ChatClientBuilder)null!).UseFunctionInvocation()); + } + [Fact] public void Ctor_HasExpectedDefaults() { @@ -294,6 +305,89 @@ public async Task RejectsMultipleChoicesAsync() Assert.Single(chat); // It didn't add anything to the chat history } + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task FunctionInvocationsLogged(LogLevel level) + { + using CapturingLoggerProvider clp = new(); + + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(clp).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")] + }; + + await InvokeAndAssertAsync(options, [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1", new Dictionary { ["arg1"] = "value1" })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, "world"), + ], configurePipeline: b => b.Use(c => new FunctionInvokingChatClient(c, services.GetRequiredService>()))); + + if (level is LogLevel.Trace) + { + Assert.Collection(clp.Logger.Entries, + entry => Assert.True(entry.Message.Contains("Invoking Func1({") && entry.Message.Contains("\"arg1\": \"value1\"")), + entry => Assert.True(entry.Message.Contains("Func1 invocation completed. Duration:") && entry.Message.Contains("Result: \"Result 1\""))); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(clp.Logger.Entries, + entry => Assert.True(entry.Message.Contains("Invoking Func1") && !entry.Message.Contains("arg1")), + entry => Assert.True(entry.Message.Contains("Func1 invocation completed. Duration:") && !entry.Message.Contains("Result"))); + } + else + { + Assert.Empty(clp.Logger.Entries); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FunctionInvocationTrackedWithActivity(bool enableTelemetry) + { + string sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using TracerProvider? tracerProvider = enableTelemetry ? + OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build() : + null; + + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")] + }; + + await InvokeAndAssertAsync(options, [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1", new Dictionary { ["arg1"] = "value1" })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, "world"), + ], configurePipeline: b => b.Use(c => + new FunctionInvokingChatClient( + new OpenTelemetryChatClient(c, sourceName: sourceName)))); + + if (enableTelemetry) + { + Assert.Collection(activities, + activity => Assert.Equal("chat", activity.DisplayName), + activity => Assert.Equal("Func1", activity.DisplayName), + activity => Assert.Equal("chat", activity.DisplayName)); + } + else + { + Assert.Empty(activities); + } + } + private static async Task> InvokeAndAssertAsync( ChatOptions options, List plan, From f02cfa33a468d185288500795b0277ae8ddc2b99 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 7 Nov 2024 17:42:10 -0500 Subject: [PATCH 102/190] Update M.E.AI CHANGELOG.mds for latest preview (#5609) --- .../Microsoft.Extensions.AI.Abstractions/CHANGELOG.md | 6 ++++++ .../Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md | 4 ++++ src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md index 6b347a8c09d..1421517957b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 9.0.0-preview.9.24556.5 + +- Added a strongly-typed `ChatOptions.Seed` property. +- Improved `AdditionalPropertiesDictionary` with a `TryAdd` method, a strongly-typed `Enumerator`, and debugger-related attributes for improved debuggability. +- Fixed `AIJsonUtilities` schema generation for Boolean schemas. + ## 9.0.0-preview.9.24525.1 - Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md index 7929cc7e8b2..b094d59853f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## 9.0.0-preview.9.24556.5 + +- Fixed `AzureAIInferenceEmbeddingGenerator` to respect `EmbeddingGenerationOptions.Dimensions`. + ## 9.0.0-preview.9.24525.1 - Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. diff --git a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md index e2dae2e6e37..a84e0a00909 100644 --- a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## 9.0.0-preview.9.24556.5 + +- Added `UseEmbeddingGenerationOptions` and corresponding `ConfigureOptionsEmbeddingGenerator`. + ## 9.0.0-preview.9.24525.1 - Added new `AIJsonUtilities` and `AIJsonSchemaCreateOptions` classes. From d8f84d7467726177ea125bfc8d0231c2dfad7c96 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Fri, 8 Nov 2024 11:31:46 +1100 Subject: [PATCH 103/190] [main] Update dependencies from dotnet/arcade (#5610) * Update dependencies from https://github.com/dotnet/arcade build 20241016.2 Microsoft.DotNet.Arcade.Sdk , Microsoft.DotNet.Helix.Sdk From Version 9.0.0-beta.24501.3 -> To Version 9.0.0-beta.24516.2 --------- Co-authored-by: dotnet-maestro[bot] Co-authored-by: Stephen Toub --- eng/Version.Details.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 2264967d976..cbd4de80348 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -186,13 +186,13 @@ - + https://github.com/dotnet/arcade - e879259c14f58a55983b9a70dd3034cc650ee961 + 3c393bbd85ae16ddddba20d0b75035b0c6f1a52d - + https://github.com/dotnet/arcade - e879259c14f58a55983b9a70dd3034cc650ee961 + 3c393bbd85ae16ddddba20d0b75035b0c6f1a52d From c86b7ead537a3534eb7fbc748384417b6a552bf0 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 8 Nov 2024 07:46:59 -0500 Subject: [PATCH 104/190] Add ToChatCompletion{Async} methods for combining StreamingChatCompleteUpdates (#5605) --- ...StreamingChatCompletionUpdateExtensions.cs | 212 ++++++++++++++++++ ...mingChatCompletionUpdateExtensionsTests.cs | 200 +++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs new file mode 100644 index 00000000000..05ac80dd682 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs @@ -0,0 +1,212 @@ +// 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; +#if NET +using System.Runtime.InteropServices; +#endif +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S109 // Magic numbers should not be used +#pragma warning disable S127 // "for" loop stop conditions should be invariant + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods for working with instances. +/// +public static class StreamingChatCompletionUpdateExtensions +{ + /// Combines instances into a single . + /// The updates to be combined. + /// + /// to attempt to coalesce contiguous items, where applicable, + /// into a single , in order to reduce the number of individual content items that are included in + /// the manufactured instances. When , the original content items are used. + /// The default is . + /// + /// The combined . + public static ChatCompletion ToChatCompletion( + this IEnumerable updates, bool coalesceContent = true) + { + _ = Throw.IfNull(updates); + + ChatCompletion completion = new([]); + Dictionary messages = []; + + foreach (var update in updates) + { + ProcessUpdate(update, messages, completion); + } + + AddMessagesToCompletion(messages, completion, coalesceContent); + + return completion; + } + + /// Combines instances into a single . + /// The updates to be combined. + /// + /// to attempt to coalesce contiguous items, where applicable, + /// into a single , in order to reduce the number of individual content items that are included in + /// the manufactured instances. When , the original content items are used. + /// The default is . + /// + /// The to monitor for cancellation requests. The default is . + /// The combined . + public static Task ToChatCompletionAsync( + this IAsyncEnumerable updates, bool coalesceContent = true, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(updates); + + return ToChatCompletionAsync(updates, coalesceContent, cancellationToken); + + static async Task ToChatCompletionAsync( + IAsyncEnumerable updates, bool coalesceContent, CancellationToken cancellationToken) + { + ChatCompletion completion = new([]); + Dictionary messages = []; + + await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + ProcessUpdate(update, messages, completion); + } + + AddMessagesToCompletion(messages, completion, coalesceContent); + + return completion; + } + } + + /// Processes the , incorporating its contents into and . + /// The update to process. + /// The dictionary mapping to the being built for that choice. + /// The object whose properties should be updated based on . + private static void ProcessUpdate(StreamingChatCompletionUpdate update, Dictionary messages, ChatCompletion completion) + { + completion.CompletionId ??= update.CompletionId; + completion.CreatedAt ??= update.CreatedAt; + completion.FinishReason ??= update.FinishReason; + completion.ModelId ??= update.ModelId; + +#if NET + ChatMessage message = CollectionsMarshal.GetValueRefOrAddDefault(messages, update.ChoiceIndex, out _) ??= + new(default, new List()); +#else + if (!messages.TryGetValue(update.ChoiceIndex, out ChatMessage? message)) + { + messages[update.ChoiceIndex] = message = new(default, new List()); + } +#endif + + ((List)message.Contents).AddRange(update.Contents); + + message.AuthorName ??= update.AuthorName; + if (update.Role is ChatRole role && message.Role == default) + { + message.Role = role; + } + + if (update.AdditionalProperties is not null) + { + if (message.AdditionalProperties is null) + { + message.AdditionalProperties = new(update.AdditionalProperties); + } + else + { + foreach (var entry in update.AdditionalProperties) + { + // Use first-wins behavior to match the behavior of the other properties. + _ = message.AdditionalProperties.TryAdd(entry.Key, entry.Value); + } + } + } + } + + /// Finalizes the object by transferring the into it. + /// The messages to process further and transfer into . + /// The result being built. + /// The corresponding option value provided to or . + private static void AddMessagesToCompletion(Dictionary messages, ChatCompletion completion, bool coalesceContent) + { + foreach (var entry in messages) + { + if (entry.Value.Role == default) + { + entry.Value.Role = ChatRole.Assistant; + } + + if (coalesceContent) + { + CoalesceTextContent((List)entry.Value.Contents); + } + + completion.Choices.Add(entry.Value); + + if (completion.Usage is null) + { + foreach (var content in entry.Value.Contents) + { + if (content is UsageContent c) + { + completion.Usage = c.Details; + break; + } + } + } + } + } + + /// Coalesces sequential content elements. + private static void CoalesceTextContent(List contents) + { + StringBuilder? coalescedText = null; + + // Iterate through all of the items in the list looking for contiguous items that can be coalesced. + 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 TextContent firstText) + { + start++; + continue; + } + + if (contents[start + 1] is not TextContent secondText) + { + start += 2; + continue; + } + + // Append the text from those nodes and continue appending subsequent TextContents until we run out. + // We null out nodes as their text is appended so that we can later remove them all in one O(N) operation. + coalescedText ??= new(); + _ = coalescedText.Clear().Append(firstText.Text).Append(secondText.Text); + contents[start + 1] = null!; + int i = start + 2; + for (; i < contents.Count && contents[i] is TextContent next; i++) + { + _ = coalescedText.Append(next.Text); + contents[i] = null!; + } + + // Store the replacement node. + contents[start] = new TextContent(coalescedText.ToString()) + { + // We inherit the properties of the first text node. We don't currently propagate additional + // properties from the subsequent nodes. If we ever need to, we can add that here. + AdditionalProperties = firstText.AdditionalProperties?.Clone(), + }; + + start = i; + } + + // Remove all of the null slots left over from the coalescing process. + _ = contents.RemoveAll(u => u is null); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs new file mode 100644 index 00000000000..bb0f08325d5 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs @@ -0,0 +1,200 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +#pragma warning disable SA1204 // Static elements should appear before instance elements + +namespace Microsoft.Extensions.AI; + +public class StreamingChatCompletionUpdateExtensionsTests +{ + [Fact] + public void InvalidArgs_Throws() + { + Assert.Throws("updates", () => ((List)null!).ToChatCompletion()); + } + + public static IEnumerable ToChatCompletion_SuccessfullyCreatesCompletion_MemberData() + { + foreach (bool useAsync in new[] { false, true }) + { + foreach (bool? coalesceContent in new bool?[] { null, false, true }) + { + yield return new object?[] { useAsync, coalesceContent }; + } + } + } + + [Theory] + [MemberData(nameof(ToChatCompletion_SuccessfullyCreatesCompletion_MemberData))] + public async Task ToChatCompletion_SuccessfullyCreatesCompletion(bool useAsync, bool? coalesceContent) + { + StreamingChatCompletionUpdate[] updates = + [ + new() { ChoiceIndex = 0, Text = "Hello", CompletionId = "12345", CreatedAt = new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), ModelId = "model123" }, + new() { ChoiceIndex = 1, Text = "Hey", CompletionId = "12345", CreatedAt = new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), ModelId = "model124" }, + + new() { ChoiceIndex = 0, Text = ", ", AuthorName = "Someone", Role = ChatRole.User, AdditionalProperties = new() { ["a"] = "b" } }, + new() { ChoiceIndex = 1, Text = ", ", AuthorName = "Else", Role = ChatRole.System, AdditionalProperties = new() { ["g"] = "h" } }, + + new() { ChoiceIndex = 0, Text = "world!", CreatedAt = new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), AdditionalProperties = new() { ["c"] = "d" } }, + new() { ChoiceIndex = 1, Text = "you!", Role = ChatRole.Tool, CreatedAt = new DateTimeOffset(3, 2, 3, 4, 5, 6, TimeSpan.Zero), AdditionalProperties = new() { ["e"] = "f", ["i"] = 42 } }, + + new() { ChoiceIndex = 0, Contents = new[] { new UsageContent(new() { InputTokenCount = 1, OutputTokenCount = 2 }) } }, + new() { ChoiceIndex = 3, Contents = new[] { new UsageContent(new() { InputTokenCount = 4, OutputTokenCount = 5 }) } }, + ]; + + ChatCompletion completion = (coalesceContent is bool, useAsync) switch + { + (false, false) => updates.ToChatCompletion(), + (false, true) => await YieldAsync(updates).ToChatCompletionAsync(), + + (true, false) => updates.ToChatCompletion(coalesceContent.GetValueOrDefault()), + (true, true) => await YieldAsync(updates).ToChatCompletionAsync(coalesceContent.GetValueOrDefault()), + }; + Assert.NotNull(completion); + + Assert.Equal("12345", completion.CompletionId); + Assert.Equal(new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), completion.CreatedAt); + Assert.Equal("model123", completion.ModelId); + Assert.Same(Assert.IsType(updates[6].Contents[0]).Details, completion.Usage); + + Assert.Equal(3, completion.Choices.Count); + + ChatMessage message = completion.Choices[0]; + Assert.Equal(ChatRole.User, message.Role); + Assert.Equal("Someone", message.AuthorName); + Assert.NotNull(message.AdditionalProperties); + Assert.Equal(2, message.AdditionalProperties.Count); + Assert.Equal("b", message.AdditionalProperties["a"]); + Assert.Equal("d", message.AdditionalProperties["c"]); + + message = completion.Choices[1]; + Assert.Equal(ChatRole.System, message.Role); + Assert.Equal("Else", message.AuthorName); + Assert.NotNull(message.AdditionalProperties); + Assert.Equal(3, message.AdditionalProperties.Count); + Assert.Equal("h", message.AdditionalProperties["g"]); + Assert.Equal("f", message.AdditionalProperties["e"]); + Assert.Equal(42, message.AdditionalProperties["i"]); + + message = completion.Choices[2]; + Assert.Equal(ChatRole.Assistant, message.Role); + Assert.Null(message.AuthorName); + Assert.Null(message.AdditionalProperties); + Assert.Same(updates[7].Contents[0], Assert.Single(message.Contents)); + + if (coalesceContent is null or true) + { + Assert.Equal("Hello, world!", completion.Choices[0].Text); + Assert.Equal("Hey, you!", completion.Choices[1].Text); + Assert.Null(completion.Choices[2].Text); + } + else + { + Assert.Equal("Hello", completion.Choices[0].Contents[0].ToString()); + Assert.Equal(", ", completion.Choices[0].Contents[1].ToString()); + Assert.Equal("world!", completion.Choices[0].Contents[2].ToString()); + + Assert.Equal("Hey", completion.Choices[1].Contents[0].ToString()); + Assert.Equal(", ", completion.Choices[1].Contents[1].ToString()); + Assert.Equal("you!", completion.Choices[1].Contents[2].ToString()); + + Assert.Null(completion.Choices[2].Text); + } + } + + public static IEnumerable ToChatCompletion_Coalescing_VariousSequenceAndGapLengths_MemberData() + { + foreach (bool useAsync in new[] { false, true }) + { + for (int numSequences = 1; numSequences <= 3; numSequences++) + { + for (int sequenceLength = 1; sequenceLength <= 3; sequenceLength++) + { + for (int gapLength = 1; gapLength <= 3; gapLength++) + { + foreach (bool gapBeginningEnd in new[] { false, true }) + { + yield return new object[] { useAsync, numSequences, sequenceLength, gapLength, false }; + } + } + } + } + } + } + + [Theory] + [MemberData(nameof(ToChatCompletion_Coalescing_VariousSequenceAndGapLengths_MemberData))] + public async Task ToChatCompletion_Coalescing_VariousSequenceAndGapLengths(bool useAsync, int numSequences, int sequenceLength, int gapLength, bool gapBeginningEnd) + { + List updates = []; + + List expected = []; + + if (gapBeginningEnd) + { + AddGap(); + } + + for (int sequenceNum = 0; sequenceNum < numSequences; sequenceNum++) + { + StringBuilder sb = new(); + for (int i = 0; i < sequenceLength; i++) + { + string text = $"{(char)('A' + sequenceNum)}{i}"; + updates.Add(new() { Text = text }); + sb.Append(text); + } + + expected.Add(sb.ToString()); + + if (sequenceNum < numSequences - 1) + { + AddGap(); + } + } + + if (gapBeginningEnd) + { + AddGap(); + } + + void AddGap() + { + for (int i = 0; i < gapLength; i++) + { + updates.Add(new() { Contents = [new ImageContent("https://uri")] }); + } + } + + ChatCompletion completion = useAsync ? await YieldAsync(updates).ToChatCompletionAsync() : updates.ToChatCompletion(); + Assert.Single(completion.Choices); + + ChatMessage message = completion.Message; + Assert.Equal(expected.Count + (gapLength * ((numSequences - 1) + (gapBeginningEnd ? 2 : 0))), message.Contents.Count); + + TextContent[] contents = message.Contents.OfType().ToArray(); + Assert.Equal(expected.Count, contents.Length); + for (int i = 0; i < expected.Count; i++) + { + Assert.Equal(expected[i], contents[i].Text); + } + } + + private static async IAsyncEnumerable YieldAsync(IEnumerable updates) + { + foreach (StreamingChatCompletionUpdate update in updates) + { + await Task.Yield(); + yield return update; + } + } +} From 3f1f59cd52c58ab7e5666ba4c8ea6176f606f3d7 Mon Sep 17 00:00:00 2001 From: Genevieve Warren <24882762+gewarren@users.noreply.github.com> Date: Sun, 10 Nov 2024 10:11:49 -0800 Subject: [PATCH 105/190] Docs improvements (#5613) * docs improvements * small addition * Apply suggestions from code review Co-authored-by: Stephen Toub --------- Co-authored-by: Stephen Toub --- src/Generators/Shared/RoslynExtensions.cs | 4 +- .../AITool.cs | 2 +- .../ChatCompletion/ChatClientExtensions.cs | 4 +- .../ChatCompletion/ChatClientMetadata.cs | 6 +- .../ChatCompletion/ChatCompletion.cs | 2 +- .../ChatCompletion/ChatFinishReason.cs | 12 +-- .../ChatCompletion/ChatMessage.cs | 2 +- .../ChatCompletion/ChatOptions.cs | 4 +- .../ChatCompletion/ChatResponseFormat.cs | 2 +- .../ChatCompletion/ChatRole.cs | 14 +-- .../ChatCompletion/ChatToolMode.cs | 4 +- .../ChatCompletion/DelegatingChatClient.cs | 2 +- .../ChatCompletion/IChatClient.cs | 14 +-- .../ChatCompletion/RequiredChatToolMode.cs | 10 +-- .../Contents/AudioContent.cs | 4 +- .../Contents/DataContent.cs | 22 ++--- .../Contents/FunctionResultContent.cs | 16 ++-- .../Contents/ImageContent.cs | 4 +- .../DelegatingEmbeddingGenerator.cs | 8 +- .../EmbeddingGeneratorExtensions.cs | 8 +- .../Embeddings/EmbeddingGeneratorMetadata.cs | 6 +- .../Embeddings/IEmbeddingGenerator.cs | 4 +- .../Functions/AIFunctionMetadata.cs | 6 +- .../Functions/AIFunctionParameterMetadata.cs | 4 +- .../AIFunctionReturnParameterMetadata.cs | 2 +- .../Utilities/AIJsonSchemaCreateOptions.cs | 2 +- .../Utilities/AIJsonUtilities.Schema.cs | 4 +- .../AzureAIInferenceChatClient.cs | 6 +- .../AzureAIInferenceEmbeddingGenerator.cs | 6 +- .../AzureAIInferenceExtensions.cs | 8 +- .../OllamaChatClient.cs | 10 +-- .../OllamaEmbeddingGenerator.cs | 10 +-- .../OpenAIChatClient.cs | 2 +- .../OpenAIClientExtensions.cs | 8 +- .../ChatCompletion/ChatCompletion{T}.cs | 10 ++- .../ConfigureOptionsChatClient.cs | 4 +- ...igureOptionsChatClientBuilderExtensions.cs | 2 +- .../FunctionInvokingChatClient.cs | 90 ++++++++++--------- .../ChatCompletion/OpenTelemetryChatClient.cs | 13 ++- ...penTelemetryChatClientBuilderExtensions.cs | 2 +- .../Embeddings/CachingEmbeddingGenerator.cs | 2 +- .../ConfigureOptionsEmbeddingGenerator.cs | 2 +- ...ionsEmbeddingGeneratorBuilderExtensions.cs | 2 +- .../DistributedCachingEmbeddingGenerator.cs | 2 +- .../OpenTelemetryEmbeddingGenerator.cs | 4 +- ...etryEmbeddingGeneratorBuilderExtensions.cs | 2 +- .../Functions/AIFunctionContext.cs | 4 +- .../Functions/AIFunctionFactory.cs | 2 +- .../AIFunctionFactoryCreateOptions.cs | 32 +++---- 49 files changed, 204 insertions(+), 191 deletions(-) diff --git a/src/Generators/Shared/RoslynExtensions.cs b/src/Generators/Shared/RoslynExtensions.cs index a4b9a2ec65d..82860a09f59 100644 --- a/src/Generators/Shared/RoslynExtensions.cs +++ b/src/Generators/Shared/RoslynExtensions.cs @@ -38,7 +38,7 @@ internal static class RoslynExtensions /// /// /// The to consider for analysis. - /// The fully-qualified metadata type name to find. + /// The fully qualified metadata type name to find. /// The symbol to use for code analysis; otherwise, . // Copied from: https://github.com/dotnet/roslyn/blob/af7b0ebe2b0ed5c335a928626c25620566372dd1/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Extensions/CompilationExtensions.cs public static INamedTypeSymbol? GetBestTypeByMetadataName(this Compilation compilation, string fullyQualifiedMetadataName) @@ -94,7 +94,7 @@ internal static class RoslynExtensions /// /// A thin wrapper over , - /// but taking the type itself rather than the fully-qualified metadata type name. + /// but taking the type itself rather than the fully qualified metadata type name. /// /// The to consider for analysis. /// The type to find. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs index 0cdcd60e63e..ebbc6751c04 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs @@ -3,7 +3,7 @@ namespace Microsoft.Extensions.AI; -/// Represents a tool that may be specified to an AI service. +/// Represents a tool that can be specified to an AI service. public class AITool { /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs index 9e2019d9e52..655b9f3a281 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientExtensions.cs @@ -14,10 +14,10 @@ public static class ChatClientExtensions /// Asks the for an object of type . /// The type of the object to be retrieved. /// The client. - /// An optional key that may be used to help identify the target service. + /// An optional key that can be used to help identify the target service. /// The found object, otherwise . /// - /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the , + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , /// including itself or any services it might be wrapping. /// public static TService? GetService(this IChatClient client, object? serviceKey = null) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs index b98455daf2a..d21d3b20585 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs @@ -11,7 +11,7 @@ public class ChatClientMetadata /// Initializes a new instance of the class. /// The name of the chat completion provider, if applicable. /// The URL for accessing the chat completion provider, if applicable. - /// The id of the chat completion model used, if applicable. + /// The ID of the chat completion model used, if applicable. public ChatClientMetadata(string? providerName = null, Uri? providerUri = null, string? modelId = null) { ModelId = modelId; @@ -25,7 +25,7 @@ public ChatClientMetadata(string? providerName = null, Uri? providerUri = null, /// Gets the URL for accessing the chat completion provider. public Uri? ProviderUri { get; } - /// Gets the id of the model used by this chat completion provider. - /// This may be null if either the name is unknown or there are multiple possible models associated with this instance. + /// Gets the ID of the model used by this chat completion provider. + /// This value can be null if either the name is unknown or there are multiple possible models associated with this instance. public string? ModelId { get; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs index 0d3d28bd86b..89182e26165 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs @@ -40,7 +40,7 @@ public IList Choices /// Gets the chat completion message. /// /// If there are multiple choices, this property returns the first choice. - /// If is empty, this will throw. Use to access all choices directly."/>. + /// If is empty, this property will throw. Use to access all choices directly. /// [JsonIgnore] public ChatMessage Message diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs index 08a5630c51b..5ccd99af718 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs @@ -42,9 +42,9 @@ public ChatFinishReason(string value) /// /// Compares two instances. /// - /// Left argument of the comparison. - /// Right argument of the comparison. - /// when equal, otherwise. + /// The left argument of the comparison. + /// The right argument of the comparison. + /// if the two instances are equal; if they aren't equal. public static bool operator ==(ChatFinishReason left, ChatFinishReason right) { return left.Equals(right); @@ -53,9 +53,9 @@ public ChatFinishReason(string value) /// /// Compares two instances. /// - /// Left argument of the comparison. - /// Right argument of the comparison. - /// when not equal, otherwise. + /// The left argument of the comparison. + /// The right argument of the comparison. + /// if the two instances aren't equal; if they are equal. public static bool operator !=(ChatFinishReason left, ChatFinishReason right) { return !(left == right); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs index 4fdb138b615..ccbc1cae97b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs @@ -55,7 +55,7 @@ public string? AuthorName /// /// /// If there is no instance in , then the getter returns , - /// and the setter will add a new instance with the provided value. + /// and the setter adds a new instance with the provided value. /// [JsonIgnore] public string? Text diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index 0a4f6f58296..63ccb69031a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -35,12 +35,12 @@ public class ChatOptions /// /// /// If null, no response format is specified and the client will use its default. - /// This may be set to to specify that the response should be unstructured text, + /// This property can be set to to specify that the response should be unstructured text, /// to to specify that the response should be structured JSON data, or /// an instance of constructed with a specific JSON schema to request that the /// response be structured JSON data according to that schema. It is up to the client implementation if or how /// to honor the request. If the client implementation doesn't recognize the specific kind of , - /// it may be ignored. + /// it can be ignored. /// public ChatResponseFormat? ResponseFormat { get; set; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs index 6f1574fe400..006acfe835c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs @@ -29,7 +29,7 @@ private protected ChatResponseFormat() /// Creates a representing structured JSON data with the specified schema. /// The JSON schema. - /// An optional name of the schema, e.g. if the schema represents a particular class, this could be the name of the class. + /// An optional name of the schema. For example, if the schema represents a particular class, this could be the name of the class. /// An optional description of the schema. /// The instance. public static ChatResponseFormatJson ForJsonSchema( diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs index f898bb58892..0b5f72adfa5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatRole.cs @@ -32,7 +32,7 @@ namespace Microsoft.Extensions.AI; /// Gets the value associated with this . /// /// - /// The value is what will be serialized into the "role" message field of the Chat Message format. + /// The value will be serialized into the "role" message field of the Chat Message format. /// public string Value { get; } @@ -50,9 +50,9 @@ public ChatRole(string value) /// Returns a value indicating whether two instances are equivalent, as determined by a /// case-insensitive comparison of their values. /// - /// the first instance to compare. - /// the second instance to compare. - /// true if left and right are both null or have equivalent values; false otherwise. + /// The first instance to compare. + /// The second instance to compare. + /// if left and right are both null or have equivalent values; otherwise, . public static bool operator ==(ChatRole left, ChatRole right) { return left.Equals(right); @@ -62,9 +62,9 @@ public ChatRole(string value) /// Returns a value indicating whether two instances are not equivalent, as determined by a /// case-insensitive comparison of their values. /// - /// the first instance to compare. - /// the second instance to compare. - /// false if left and right are both null or have equivalent values; true otherwise. + /// The first instance to compare. + /// The second instance to compare. + /// if left and right have different values; if they have equivalent values or are both null. public static bool operator !=(ChatRole left, ChatRole right) { return !(left == right); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs index 27b8c70e804..0e279042abd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs @@ -29,14 +29,14 @@ private protected ChatToolMode() /// Gets a predefined indicating that tool usage is optional. /// /// - /// may contain zero or more + /// can contain zero or more /// instances, and the is free to invoke zero or more of them. /// public static AutoChatToolMode Auto { get; } = new AutoChatToolMode(); /// /// Gets a predefined indicating that tool usage is required, - /// but that any tool may be selected. At least one tool must be provided in . + /// but that any tool can be selected. At least one tool must be provided in . /// public static RequiredChatToolMode RequireAny { get; } = new(requiredFunctionName: null); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs index d92590bad92..941ffeb722b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs @@ -38,7 +38,7 @@ public void Dispose() protected IChatClient InnerClient { get; } /// Provides a mechanism for releasing unmanaged resources. - /// true if being called from ; otherwise, false. + /// if being called from ; otherwise, . protected virtual void Dispose(bool disposing) { if (disposing) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs index 4e3fd126b37..54e1dd9da98 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.AI; /// It is expected that all implementations of support being used by multiple requests concurrently. /// /// -/// However, implementations of may mutate the arguments supplied to and +/// However, implementations of might mutate the arguments supplied to and /// , such as by adding additional messages to the messages list or configuring the options /// instance. Thus, consumers of the interface either should avoid using shared instances of these arguments for concurrent /// invocations or should otherwise ensure by construction that no instances are used which might employ @@ -31,8 +31,8 @@ public interface IChatClient : IDisposable /// The to monitor for cancellation requests. The default is . /// The response messages generated by the client. /// - /// The returned messages will not have been added to . However, any intermediate messages generated implicitly - /// by the client, including any messages for roundtrips to the model as part of the implementation of this request, will be included. + /// The returned messages aren't added to . However, any intermediate messages generated implicitly + /// by the client, including any messages for roundtrips to the model as part of the implementation of this request, are included. /// Task CompleteAsync( IList chatMessages, @@ -45,8 +45,8 @@ Task CompleteAsync( /// The to monitor for cancellation requests. The default is . /// The response messages generated by the client. /// - /// The returned messages will not have been added to . However, any intermediate messages generated implicitly - /// by the client, including any messages for roundtrips to the model as part of the implementation of this request, will be included. + /// The returned messages aren't added to . However, any intermediate messages generated implicitly + /// by the client, including any messages for roundtrips to the model as part of the implementation of this request, are included. /// IAsyncEnumerable CompleteStreamingAsync( IList chatMessages, @@ -58,10 +58,10 @@ IAsyncEnumerable CompleteStreamingAsync( /// Asks the for an object of the specified type . /// The type of object being requested. - /// An optional key that may be used to help identify the target service. + /// An optional key that can be used to help identify the target service. /// The found object, otherwise . /// - /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the , + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , /// including itself or any services it might be wrapping. /// object? GetService(Type serviceType, object? serviceKey = null); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs index a920afaef17..ef410ba24db 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs @@ -8,8 +8,8 @@ namespace Microsoft.Extensions.AI; /// -/// Indicates that a chat tool must be called. It may optionally nominate a specific function, -/// or if not, indicates that any of them may be selected. +/// Represents a mode where a chat tool must be called. This class can optionally nominate a specific function +/// or indicate that any of the functions can be selected. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class RequiredChatToolMode : ChatToolMode @@ -18,7 +18,7 @@ public sealed class RequiredChatToolMode : ChatToolMode /// Gets the name of a specific that must be called. /// /// - /// If the value is , any available function may be selected (but at least one must be). + /// If the value is , any available function can be selected (but at least one must be). /// public string? RequiredFunctionName { get; } @@ -27,8 +27,8 @@ public sealed class RequiredChatToolMode : ChatToolMode /// /// The name of the function that must be called. /// - /// may be . However, it is preferable to use - /// when any function may be selected. + /// can be . However, it's preferable to use + /// when any function can be selected. /// public RequiredChatToolMode(string? requiredFunctionName) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AudioContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AudioContent.cs index 84354a95b1d..356cce78413 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AudioContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AudioContent.cs @@ -15,7 +15,7 @@ public class AudioContent : DataContent /// /// Initializes a new instance of the class. /// - /// The URI of the content. This may be a data URI. + /// The URI of the content. This can be a data URI. /// The media type (also known as MIME type) represented by the content. public AudioContent(Uri uri, string? mediaType = null) : base(uri, mediaType) @@ -25,7 +25,7 @@ public AudioContent(Uri uri, string? mediaType = null) /// /// Initializes a new instance of the class. /// - /// The URI of the content. This may be a data URI. + /// The URI of the content. This can be a data URI. /// The media type (also known as MIME type) represented by the content. [JsonConstructor] public AudioContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs index 5ed17aae1b5..8eb0afea4d6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -34,7 +34,7 @@ public class DataContent : AIContent /// The string-based representation of the URI, including any data in the instance. private string? _uri; - /// The data, lazily-initialized if the data is provided in a data URI. + /// The data, lazily initialized if the data is provided in a data URI. private ReadOnlyMemory? _data; /// Parsed data URI information. @@ -43,7 +43,7 @@ public class DataContent : AIContent /// /// Initializes a new instance of the class. /// - /// The URI of the content. This may be a data URI. + /// The URI of the content. This can be a data URI. /// The media type (also known as MIME type) represented by the content. public DataContent(Uri uri, string? mediaType = null) : this(Throw.IfNull(uri).ToString(), mediaType) @@ -53,7 +53,7 @@ public DataContent(Uri uri, string? mediaType = null) /// /// Initializes a new instance of the class. /// - /// The URI of the content. This may be a data URI. + /// The URI of the content. This can be a data URI. /// The media type (also known as MIME type) represented by the content. [JsonConstructor] public DataContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null) @@ -116,7 +116,7 @@ private static void ValidateMediaType(ref string? mediaType) /// Gets the URI for this . /// /// The returned URI is always a valid URI string, even if the instance was constructed from a - /// or from a . In the case of a , this will return a data URI containing + /// or from a . In the case of a , this property returns a data URI containing /// that data. /// [StringSyntax(StringSyntaxAttribute.Uri)] @@ -155,10 +155,10 @@ public string Uri /// Gets the media type (also known as MIME type) of the content. /// - /// If the media type was explicitly specified, this property will return that value. + /// If the media type was explicitly specified, this property returns that value. /// If the media type was not explicitly specified, but a data URI was supplied and that data URI contained a non-default - /// media type, that media type will be returned. - /// Otherwise, this will return null. + /// media type, that media type is returned. + /// Otherwise, this property returns null. /// [JsonPropertyOrder(1)] public string? MediaType { get; private set; } @@ -167,17 +167,17 @@ public string Uri /// Gets a value indicating whether the content contains data rather than only being a reference to data. /// /// - /// If the instance is constructed from a or from a data URI, this property will return , + /// If the instance is constructed from a or from a data URI, this property returns , /// as the instance actually contains all of the data it represents. If, however, the instance was constructed from another form of URI, one - /// that simply references where the data can be found but doesn't actually contain the data, this property will return . + /// that simply references where the data can be found but doesn't actually contain the data, this property returns . /// [JsonIgnore] public bool ContainsData => _dataUri is not null || _data is not null; /// Gets the data represented by this instance. /// - /// If is , this property will return the represented data. - /// If is , this property will return . + /// If is , this property returns the represented data. + /// If is , this property returns . /// [MemberNotNullWhen(true, nameof(ContainsData))] [JsonIgnore] diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs index 731716e5427..b05553f16b8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs @@ -21,8 +21,8 @@ public sealed class FunctionResultContent : AIContent /// The function call ID for which this is the result. /// The function name that produced the result. /// - /// This may be if the function returned , if the function was void-returning - /// and thus had no result, or if the function call failed. Typically, however, in order to provide meaningfully representative + /// if the function returned or was void-returning + /// and thus had no result, or if the function call failed. Typically, however, to provide meaningfully representative /// information to an AI service, a human-readable representation of those conditions should be supplied. /// [JsonConstructor] @@ -37,7 +37,7 @@ public FunctionResultContent(string callId, string name, object? result) /// Gets or sets the ID of the function call for which this is the result. /// /// - /// If this is the result for a , this should contain the same + /// If this is the result for a , this property should contain the same /// value. /// public string CallId { get; set; } @@ -51,8 +51,8 @@ public FunctionResultContent(string callId, string name, object? result) /// Gets or sets the result of the function call, or a generic error message if the function call failed. /// /// - /// This may be if the function returned , if the function was void-returning - /// and thus had no result, or if the function call failed. Typically, however, in order to provide meaningfully representative + /// if the function returned or was void-returning + /// and thus had no result, or if the function call failed. Typically, however, to provide meaningfully representative /// information to an AI service, a human-readable representation of those conditions should be supplied. /// public object? Result { get; set; } @@ -61,9 +61,9 @@ public FunctionResultContent(string callId, string name, object? result) /// Gets or sets an exception that occurred if the function call failed. /// /// - /// This property is for information purposes only. The is not serialized as part of serializing - /// instances of this class with ; as such, upon deserialization, this property will be . - /// Consumers should not rely on indicating success. + /// This property is for informational purposes only. The is not serialized as part of serializing + /// instances of this class with . As such, upon deserialization, this property will be . + /// Consumers should not rely on indicating success. /// [JsonIgnore] public Exception? Exception { get; set; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageContent.cs index d376586c993..df559152412 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageContent.cs @@ -15,7 +15,7 @@ public class ImageContent : DataContent /// /// Initializes a new instance of the class. /// - /// The URI of the content. This may be a data URI. + /// The URI of the content. This can be a data URI. /// The media type (also known as MIME type) represented by the content. public ImageContent(Uri uri, string? mediaType = null) : base(uri, mediaType) @@ -25,7 +25,7 @@ public ImageContent(Uri uri, string? mediaType = null) /// /// Initializes a new instance of the class. /// - /// The URI of the content. This may be a data URI. + /// The URI of the content. This can be a data URI. /// The media type (also known as MIME type) represented by the content. [JsonConstructor] public ImageContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs index 590817d4e11..7edbe7cf5bd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/DelegatingEmbeddingGenerator.cs @@ -12,10 +12,10 @@ namespace Microsoft.Extensions.AI; /// /// Provides an optional base class for an that passes through calls to another instance. /// -/// Specifies the type of the input passed to the generator. -/// Specifies the type of the embedding instance produced by the generator. +/// The type of the input passed to the generator. +/// The type of the embedding instance produced by the generator. /// -/// This is recommended as a base type when building generators that can be chained in any order around an underlying . +/// This type is recommended as a base type when building generators that can be chained in any order around an underlying . /// The default implementation simply passes each call to the inner generator instance. /// public class DelegatingEmbeddingGenerator : IEmbeddingGenerator @@ -41,7 +41,7 @@ public void Dispose() } /// Provides a mechanism for releasing unmanaged resources. - /// true if being called from ; otherwise, false. + /// if being called from ; otherwise, . protected virtual void Dispose(bool disposing) { if (disposing) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs index 8a388d361b9..1593cdd33a8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs @@ -20,10 +20,10 @@ public static class EmbeddingGeneratorExtensions /// The numeric type of the embedding data. /// The type of the object to be retrieved. /// The generator. - /// An optional key that may be used to help identify the target service. + /// An optional key that can be used to help identify the target service. /// The found object, otherwise . /// - /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the /// , including itself or any services it might be wrapping. /// public static TService? GetService(this IEmbeddingGenerator generator, object? serviceKey = null) @@ -43,10 +43,10 @@ public static class EmbeddingGeneratorExtensions /// Asks the for an object of type . /// The type of the object to be retrieved. /// The generator. - /// An optional key that may be used to help identify the target service. + /// An optional key that can be used to help identify the target service. /// The found object, otherwise . /// - /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the /// , including itself or any services it might be wrapping. /// public static TService? GetService(this IEmbeddingGenerator> generator, object? serviceKey = null) => diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs index 39bdd61d3ae..0f2f7b23af5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs @@ -11,7 +11,7 @@ public class EmbeddingGeneratorMetadata /// Initializes a new instance of the class. /// The name of the embedding generation provider, if applicable. /// The URL for accessing the embedding generation provider, if applicable. - /// The id of the embedding generation model used, if applicable. + /// The ID of the embedding generation model used, if applicable. /// The number of dimensions in vectors produced by this generator, if applicable. public EmbeddingGeneratorMetadata(string? providerName = null, Uri? providerUri = null, string? modelId = null, int? dimensions = null) { @@ -27,8 +27,8 @@ public EmbeddingGeneratorMetadata(string? providerName = null, Uri? providerUri /// Gets the URL for accessing the embedding generation provider. public Uri? ProviderUri { get; } - /// Gets the id of the model used by this embedding generation provider. - /// This may be null if either the name is unknown or there are multiple possible models associated with this instance. + /// Gets the ID of the model used by this embedding generation provider. + /// This value can be null if either the name is unknown or there are multiple possible models associated with this instance. public string? ModelId { get; } /// Gets the number of dimensions in the embeddings produced by this instance. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs index 9f9c9f1325f..84d02c0de34 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/IEmbeddingGenerator.cs @@ -42,10 +42,10 @@ Task> GenerateAsync( /// Asks the for an object of the specified type . /// The type of object being requested. - /// An optional key that may be used to help identify the target service. + /// An optional key that can be used to help identify the target service. /// The found object, otherwise . /// - /// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the /// , including itself or any services it might be wrapping. /// object? GetService(Type serviceType, object? serviceKey = null); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs index 03dac25d15f..528212c4b2b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionMetadata.cs @@ -73,7 +73,7 @@ public string Description } /// Gets the metadata for the parameters to the function. - /// If the function has no parameters, the returned list will be empty. + /// If the function has no parameters, the returned list is empty. public IReadOnlyList Parameters { get => _parameters; @@ -93,7 +93,7 @@ public IReadOnlyList Parameters } /// Gets parameter metadata for the return parameter. - /// If the function has no return parameter, the returned value will be a default instance of a . + /// If the function has no return parameter, the value is a default instance of an . public AIFunctionReturnParameterMetadata ReturnParameter { get => _returnParameter; @@ -107,6 +107,6 @@ public AIFunctionReturnParameterMetadata ReturnParameter init => _additionalProperties = Throw.IfNull(value); } - /// Gets a that may be used to marshal function parameters. + /// Gets a that can be used to marshal function parameters. public JsonSerializerOptions? JsonSerializerOptions { get; init; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs index b9bd4d83841..b2e77f619db 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionParameterMetadata.cs @@ -7,7 +7,7 @@ namespace Microsoft.Extensions.AI; /// -/// Provides read-only metadata for a parameter. +/// Provides read-only metadata for an parameter. /// public sealed class AIFunctionParameterMetadata { @@ -24,7 +24,7 @@ public AIFunctionParameterMetadata(string name) /// Initializes a new instance of the class as a copy of another . /// The was null. - /// This creates a shallow clone of . + /// This constructor creates a shallow clone of . public AIFunctionParameterMetadata(AIFunctionParameterMetadata metadata) { _ = Throw.IfNull(metadata); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs index 17aec4d2fdb..e96e67d4806 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionReturnParameterMetadata.cs @@ -7,7 +7,7 @@ namespace Microsoft.Extensions.AI; /// -/// Provides read-only metadata for a 's return parameter. +/// Provides read-only metadata for an 's return parameter. /// public sealed class AIFunctionReturnParameterMetadata { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs index afa2f236c69..150673560df 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs @@ -4,7 +4,7 @@ namespace Microsoft.Extensions.AI; /// -/// An options class for configuring the behavior of JSON schema creation functionality. +/// Provides options for configuring the behavior of JSON schema creation functionality. /// public sealed class AIJsonSchemaCreateOptions { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs index b555148df8b..fa893450d0f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs @@ -95,7 +95,7 @@ public static JsonElement ResolveParameterJsonSchema( /// The type of the parameter. /// The name of the parameter. /// The description of the parameter. - /// Whether the parameter is optional. + /// if the parameter is optional; otherwise, . /// The default value of the optional parameter, if applicable. /// The options used to extract the schema from the specified type. /// The options controlling schema inference. @@ -130,7 +130,7 @@ public static JsonElement CreateParameterJsonSchema( /// Creates a JSON schema for the specified type. /// The type for which to generate the schema. /// The description of the parameter. - /// Whether the parameter is optional. + /// if the parameter is optional; otherwise, . /// The default value of the optional parameter, if applicable. /// The options used to extract the schema from the specified type. /// The options controlling schema inference. diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 143d5928106..5c4e630da1a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -18,7 +18,7 @@ namespace Microsoft.Extensions.AI; -/// An for an Azure AI Inference . +/// Represents an for an Azure AI Inference . public sealed class AzureAIInferenceChatClient : IChatClient { /// A default schema to use when a parameter lacks a pre-defined schema. @@ -29,7 +29,7 @@ public sealed class AzureAIInferenceChatClient : IChatClient /// Initializes a new instance of the class for the specified . /// The underlying client. - /// The id of the model to use. If null, it may be provided per request via . + /// The ID of the model to use. If null, it can be provided per request via . public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, string? modelId = null) { _ = Throw.IfNull(chatCompletionsClient); @@ -301,7 +301,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, } } - // These properties are strongly-typed on ChatOptions but not on ChatCompletionsOptions. + // These properties are strongly typed on ChatOptions but not on ChatCompletionsOptions. if (options.TopK is int topK) { result.AdditionalProperties["top_k"] = new BinaryData(JsonSerializer.SerializeToUtf8Bytes(topK, JsonContext.Default.Int32)); diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs index 3f8f2adb3ff..0c785cbbd6d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs @@ -21,7 +21,7 @@ namespace Microsoft.Extensions.AI; -/// An for an Azure.AI.Inference . +/// Represents an for an Azure.AI.Inference . public sealed class AzureAIInferenceEmbeddingGenerator : IEmbeddingGenerator> { @@ -34,8 +34,8 @@ public sealed class AzureAIInferenceEmbeddingGenerator : /// Initializes a new instance of the class. /// The underlying client. /// - /// The id of the model to use. This may also be overridden per request via . - /// Either this parameter or must provide a valid model id. + /// The ID of the model to use. This can also be overridden per request via . + /// Either this parameter or must provide a valid model ID. /// /// The number of dimensions to generate in each embedding. public AzureAIInferenceEmbeddingGenerator( diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs index 05a6c87b33b..117a416b30a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceExtensions.cs @@ -10,17 +10,17 @@ public static class AzureAIInferenceExtensions { /// Gets an for use with this . /// The client. - /// The id of the model to use. If null, it may be provided per request via . - /// An that may be used to converse via the . + /// The ID of the model to use. If null, it can be provided per request via . + /// An that can be used to converse via the . public static IChatClient AsChatClient( this ChatCompletionsClient chatCompletionsClient, string? modelId = null) => new AzureAIInferenceChatClient(chatCompletionsClient, modelId); /// Gets an for use with this . /// The client. - /// The id of the model to use. If null, it may be provided per request via . + /// The ID of the model to use. If null, it can be provided per request via . /// The number of dimensions to generate in each embedding. - /// An that may be used to generate embeddings via the . + /// An that can be used to generate embeddings via the . public static IEmbeddingGenerator> AsEmbeddingGenerator( this EmbeddingsClient embeddingsClient, string? modelId = null, int? dimensions = null) => new AzureAIInferenceEmbeddingGenerator(embeddingsClient, modelId, dimensions); diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index e6084e94ab6..780b334cd93 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -19,7 +19,7 @@ namespace Microsoft.Extensions.AI; -/// An for Ollama. +/// Represents an for Ollama. public sealed class OllamaChatClient : IChatClient { private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; @@ -33,8 +33,8 @@ public sealed class OllamaChatClient : IChatClient /// Initializes a new instance of the class. /// The endpoint URI where Ollama is hosted. /// - /// The id of the model to use. This may also be overridden per request via . - /// Either this parameter or must provide a valid model id. + /// 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) @@ -45,8 +45,8 @@ public OllamaChatClient(string endpoint, string? modelId = null, HttpClient? htt /// Initializes a new instance of the class. /// The endpoint URI where Ollama is hosted. /// - /// The id of the model to use. This may also be overridden per request via . - /// Either this parameter or must provide a valid model id. + /// 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(Uri endpoint, string? modelId = null, HttpClient? httpClient = null) diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs index ea273c31b4c..288971d3534 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs @@ -12,7 +12,7 @@ namespace Microsoft.Extensions.AI; -/// An for Ollama. +/// Represents an for Ollama. public sealed class OllamaEmbeddingGenerator : IEmbeddingGenerator> { /// The api/embeddings endpoint URI. @@ -24,8 +24,8 @@ public sealed class OllamaEmbeddingGenerator : IEmbeddingGeneratorInitializes a new instance of the class. /// The endpoint URI where Ollama is hosted. /// - /// The id of the model to use. This may also be overridden per request via . - /// Either this parameter or must provide a valid model id. + /// 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) @@ -36,8 +36,8 @@ public OllamaEmbeddingGenerator(string endpoint, string? modelId = null, HttpCli /// Initializes a new instance of the class. /// The endpoint URI where Ollama is hosted. /// - /// The id of the model to use. This may also be overridden per request via . - /// Either this parameter or must provide a valid model id. + /// 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(Uri endpoint, string? modelId = null, HttpClient? httpClient = null) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 5490466b66a..6e4a8d8ec9b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -24,7 +24,7 @@ namespace Microsoft.Extensions.AI; -/// An for an OpenAI or . +/// Represents an for an OpenAI or . public sealed partial class OpenAIChatClient : IChatClient { private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index a33fd34e1ea..2bea9264730 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -13,13 +13,13 @@ public static class OpenAIClientExtensions /// Gets an for use with this . /// The client. /// The model. - /// An that may be used to converse via the . + /// An that can be used to converse via the . public static IChatClient AsChatClient(this OpenAIClient openAIClient, string modelId) => new OpenAIChatClient(openAIClient, modelId); /// Gets an for use with this . /// The client. - /// An that may be used to converse via the . + /// An that can be used to converse via the . public static IChatClient AsChatClient(this ChatClient chatClient) => new OpenAIChatClient(chatClient); @@ -27,14 +27,14 @@ public static IChatClient AsChatClient(this ChatClient chatClient) => /// The client. /// The model to use. /// The number of dimensions to generate in each embedding. - /// An that may be used to generate embeddings via the . + /// An that can be used to generate embeddings via the . public static IEmbeddingGenerator> AsEmbeddingGenerator(this OpenAIClient openAIClient, string modelId, int? dimensions = null) => new OpenAIEmbeddingGenerator(openAIClient, modelId, dimensions); /// Gets an for use with this . /// The client. /// The number of dimensions to generate in each embedding. - /// An that may be used to generate embeddings via the . + /// An that can be used to generate embeddings via the . public static IEmbeddingGenerator> AsEmbeddingGenerator(this EmbeddingClient embeddingClient, int? dimensions = null) => new OpenAIEmbeddingGenerator(embeddingClient, dimensions); } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs index 7166f04e744..182ab378b12 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatCompletion{T}.cs @@ -45,9 +45,11 @@ public ChatCompletion(ChatCompletion completion, JsonSerializerOptions serialize /// /// Gets the result of the chat completion as an instance of . + /// + /// /// If the response did not contain JSON, or if deserialization fails, this property will throw. /// To avoid exceptions, use instead. - /// + /// public T Result { get @@ -66,7 +68,7 @@ public T Result /// /// Attempts to deserialize the result to produce an instance of . /// - /// The result. + /// When this method returns, contains the result. /// if the result was produced, otherwise . public bool TryGetResult([NotNullWhen(true)] out T? result) { @@ -106,8 +108,10 @@ public bool TryGetResult([NotNullWhen(true)] out T? result) /// /// Gets or sets a value indicating whether the JSON schema has an extra object wrapper. - /// This is required for any non-JSON-object-typed values such as numbers, enum values, or arrays. /// + /// + /// The wrapper is required for any non-JSON-object-typed values such as numbers, enum values, and arrays. + /// internal bool IsWrappedInObject { get; set; } private string? GetResultAsJson() diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs index cdcbb283f12..ce2fe3ca29d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClient.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI; -/// A delegating chat client that configures a instance used by the remainder of the pipeline. +/// Represents a delegating chat client that configures a instance used by the remainder of the pipeline. public sealed class ConfigureOptionsChatClient : DelegatingChatClient { /// The callback delegate used to configure options. @@ -20,7 +20,7 @@ public sealed class ConfigureOptionsChatClient : DelegatingChatClient /// The inner client. /// /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied instance - /// (or a newly-constructed instance if the caller-supplied instance is ). + /// (or a newly constructed instance if the caller-supplied instance is ). /// /// /// The delegate is passed either a new instance of if diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs index c0ad600440b..5c160794a9f 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs @@ -17,7 +17,7 @@ public static class ConfigureOptionsChatClientBuilderExtensions /// The . /// /// The delegate to invoke to configure the instance. - /// It is passed a clone of the caller-supplied instance (or a newly-constructed instance if the caller-supplied instance is ). + /// It is passed a clone of the caller-supplied instance (or a newly constructed instance if the caller-supplied instance is ). /// /// /// This can be used to set default options. The delegate is passed either a new instance of diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 09846198802..1366422b8ea 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -29,7 +29,7 @@ namespace Microsoft.Extensions.AI; /// /// The provided implementation of is thread-safe for concurrent use so long as the /// instances employed as part of the supplied are also safe. -/// The property may be used to control whether multiple function invocation +/// The property can be used to control whether multiple function invocation /// requests as part of the same request are invocable concurrently, but even with that set to /// (the default), multiple concurrent requests to this same instance and using the same tools could result in those /// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific @@ -65,23 +65,17 @@ public FunctionInvokingChatClient(IChatClient innerClient, ILogger? logger = nul /// /// Gets or sets a value indicating whether to handle exceptions that occur during function calls. /// - /// - /// - /// If the value is , then if a function call fails with an exception, the + /// + /// if the /// underlying will be instructed to give a response without invoking - /// any further functions. - /// - /// - /// If the value is , the underlying will be allowed + /// any further functions if a function call fails with an exception. + /// if the underlying is allowed /// to continue attempting function calls until is reached. - /// - /// - /// Changing the value of this property while the client is in use may result in inconsistencies - /// as to whether errors are retried during an in-flight request. - /// - /// /// The default value is . - /// + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// as to whether errors are retried during an in-flight request. /// public bool RetryOnError { get; set; } @@ -89,23 +83,27 @@ public FunctionInvokingChatClient(IChatClient innerClient, ILogger? logger = nul /// Gets or sets a value indicating whether detailed exception information should be included /// in the chat history when calling the underlying . /// + /// + /// if the full exception message is added to the chat history + /// when calling the underlying . + /// if a generic error message is included in the chat history. + /// The default value is . + /// /// /// - /// The default value is , meaning that only a generic error message will - /// be included in the chat history. This prevents the underlying language model from disclosing - /// raw exception details to the end user, since it does not receive that information. Even in this + /// Setting the value to prevents the underlying language model from disclosing + /// raw exception details to the end user, since it doesn't receive that information. Even in this /// case, the raw object is available to application code by inspecting /// the property. /// /// - /// If set to , the full exception message will be added to the chat history - /// when calling the underlying . This can help it to bypass problems on - /// its own, for example by retrying the function call with different arguments. However it may - /// result in disclosing the raw exception information to external users, which may be a security + /// Setting the value to can help the underlying bypass problems on + /// its own, for example by retrying the function call with different arguments. However it might + /// result in disclosing the raw exception information to external users, which can be a security /// concern depending on the application scenario. /// /// - /// Changing the value of this property while the client is in use may result in inconsistencies + /// Changing the value of this property while the client is in use might result in inconsistencies /// as to whether detailed errors are provided during an in-flight request. /// /// @@ -114,21 +112,27 @@ public FunctionInvokingChatClient(IChatClient innerClient, ILogger? logger = nul /// /// Gets or sets a value indicating whether to allow concurrent invocation of functions. /// + /// + /// if multiple function calls can execute in parallel. + /// if function calls are processed serially. + /// The default value is . + /// /// - /// - /// An individual response from the inner client may contain multiple function call requests. + /// An individual response from the inner client might contain multiple function call requests. /// By default, such function calls are processed serially. Set to - /// to enable concurrent invocation such that multiple function calls may execute in parallel. - /// - /// - /// The default value is . - /// + /// to enable concurrent invocation such that multiple function calls can execute in parallel. /// public bool ConcurrentInvocation { get; set; } /// /// Gets or sets a value indicating whether to keep intermediate messages in the chat history. /// + /// + /// if intermediate messages persist in the list provided + /// to and by the caller. + /// if intermediate messages are removed prior to completing the operation. + /// The default value is . + /// /// /// /// When the inner returns to the @@ -136,13 +140,12 @@ public FunctionInvokingChatClient(IChatClient innerClient, ILogger? logger = nul /// those messages to the list of messages, along with instances /// it creates with the results of invoking the requested functions. The resulting augmented /// list of messages is then passed to the inner client in order to send the results back. - /// By default, is , and those - /// messages will persist in the list provided to + /// By default, those messages persist in the list provided to /// and by the caller. Set /// to to remove those messages prior to completing the operation. /// /// - /// Changing the value of this property while the client is in use may result in inconsistencies + /// Changing the value of this property while the client is in use might result in inconsistencies /// as to whether function calling messages are kept during an in-flight request. /// /// @@ -151,22 +154,23 @@ public FunctionInvokingChatClient(IChatClient innerClient, ILogger? logger = nul /// /// Gets or sets the maximum number of iterations per request. /// + /// + /// The maximum number of iterations per request. + /// The default value is . + /// /// /// - /// Each request to this may end up making + /// Each request to this might end up making /// multiple requests to the inner client. Each time the inner client responds with - /// a function call request, this client may perform that invocation and send the results + /// a function call request, this client might perform that invocation and send the results /// back to the inner client in a new request. This property limits the number of times /// such a roundtrip is performed. If null, there is no limit applied. If set, the value /// must be at least one, as it includes the initial request. /// /// - /// Changing the value of this property while the client is in use may result in inconsistencies + /// Changing the value of this property while the client is in use might result in inconsistencies /// as to how many iterations are allowed for an in-flight request. /// - /// - /// The default value is . - /// /// public int? MaximumIterationsPerRequest { @@ -575,7 +579,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul /// The function invocation context detailing the function to be invoked and its arguments along with additional request information. /// /// The to monitor for cancellation requests. The default is . - /// The result of the function invocation. This may be null if the function invocation returned null. + /// The result of the function invocation, or if the function invocation returned . protected virtual async Task InvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) { _ = Throw.IfNull(context); @@ -707,15 +711,15 @@ internal FunctionInvocationContext( /// Gets or sets the total number of function call requests within the iteration. /// - /// The response from the underlying client may include multiple function call requests. + /// The response from the underlying client might include multiple function call requests. /// This count indicates how many there were. /// public int FunctionCount { get; set; } /// Gets or sets a value indicating whether to terminate the request. /// - /// In response to a function call request, the function may be invoked, its result added to the chat contents, - /// and a new request issued to the wrapped client. If this property is set to true, that subsequent request + /// In response to a function call request, the function might be invoked, its result added to the chat contents, + /// and a new request issued to the wrapped client. If this property is set to , that subsequent request /// will not be issued and instead the loop immediately terminated rather than continuing until there are no /// more function call requests in responses. /// diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index 6274c39419b..3913d33145c 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -21,9 +21,9 @@ namespace Microsoft.Extensions.AI; -/// A delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// Represents a delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// -/// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/. +/// The draft specification this follows is available at . /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. /// public sealed partial class OpenTelemetryChatClient : DelegatingChatClient @@ -102,9 +102,14 @@ protected override void Dispose(bool disposing) /// /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. /// + /// + /// if potentially sensitive information should be included in telemetry; + /// if telemetry shouldn't include raw inputs and outputs. + /// The default value is . + /// /// - /// The value is by default, meaning that telemetry will include metadata such as token counts but not raw inputs - /// and outputs such as message content, function call arguments, and function call results. + /// By default, telemetry includes metadata, such as token counts, but not raw inputs + /// and outputs, such as message content, function call arguments, and function call results. /// public bool EnableSensitiveData { get; set; } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs index 6e04e16f507..59c5c81a84d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs @@ -15,7 +15,7 @@ public static class OpenTelemetryChatClientBuilderExtensions /// Adds OpenTelemetry support to the chat client pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. /// /// - /// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/. + /// The draft specification this follows is available at . /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. /// /// The . diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs index 8438d467eb6..d632431102c 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI; -/// A delegating embedding generator that caches the results of embedding generation calls. +/// Represents a delegating embedding generator that caches the results of embedding generation calls. /// The type from which embeddings will be generated. /// The type of embeddings to generate. public abstract class CachingEmbeddingGenerator : DelegatingEmbeddingGenerator diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs index d4125ef9aa0..c956a0bfe9b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs @@ -25,7 +25,7 @@ public sealed class ConfigureOptionsEmbeddingGenerator : Del /// The inner generator. /// /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied - /// instance (or a newly-constructed instance if the caller-supplied instance is ). + /// instance (or a newly constructed instance if the caller-supplied instance is ). /// /// /// The delegate is passed either a new instance of if diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs index be469786247..4bf0a7b9e6e 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs @@ -19,7 +19,7 @@ public static class ConfigureOptionsEmbeddingGeneratorBuilderExtensions /// The . /// /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied - /// instance (or a newly-constructed instance if the caller-supplied instance is ). + /// instance (or a new constructed instance if the caller-supplied instance is ). /// /// /// This can be used to set default options. The delegate is passed either a new instance of diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs index a2cf2315b8a..ecec409a1b3 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI; /// -/// A delegating embedding generator that caches the results of embedding generation calls, +/// Represents a delegating embedding generator that caches the results of embedding generation calls, /// storing them as JSON in an . /// /// The type from which embeddings will be generated. diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs index 2dce06620a8..09f762d33d0 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -13,9 +13,9 @@ namespace Microsoft.Extensions.AI; -/// A delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// Represents a delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// -/// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/. +/// The draft specification this follows is available at . /// The specification is still experimental and subject to change; as such, the telemetry output by this generator is also subject to change. /// /// The type of input used to produce embeddings. diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs index bffb9087abf..5f40f884bc4 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs @@ -15,7 +15,7 @@ public static class OpenTelemetryEmbeddingGeneratorBuilderExtensions /// Adds OpenTelemetry support to the embedding generator pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. /// /// - /// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/. + /// The draft specification this follows is available at . /// The specification is still experimental and subject to change; as such, the telemetry output by this generator is also subject to change. /// /// The type of input used to produce embeddings. diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionContext.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionContext.cs index 25f239f8883..3dcfa2215f7 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionContext.cs @@ -8,9 +8,9 @@ namespace Microsoft.Extensions.AI; /// Provides additional context to the invocation of an created by . /// -/// A delegate or passed to methods may represent a method that has a parameter +/// A delegate or passed to methods can represent a method that has a parameter /// of type . Whereas all other parameters are passed by name from the supplied collection of arguments, -/// a parameter is passed specially by the implementation, in order to pass relevant +/// an parameter is passed specially by the implementation to pass relevant /// context into the method's invocation. For example, any passed to the /// method is available from the property. /// diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index b4b022b4a39..a19608a2977 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -18,7 +18,7 @@ namespace Microsoft.Extensions.AI; -/// Provides factory methods for creating commonly-used implementations of . +/// Provides factory methods for creating commonly used implementations of . public static partial class AIFunctionFactory { /// Holds the default options instance used when creating function. diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs index 7dbfc6821e8..6483b83c0ad 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI; /// -/// Options that can be provided when creating an from a method. +/// Represents options that can be provided when creating an from a method. /// public sealed class AIFunctionFactoryCreateOptions { @@ -32,35 +32,35 @@ public JsonSerializerOptions SerializerOptions } /// Gets or sets the name to use for the function. - /// - /// If , it will default to one derived from the method represented by the passed or . - /// + /// + /// The name to use for the function. The default value is a name derived from the method represented by the passed or . + /// public string? Name { get; set; } /// Gets or sets the description to use for the function. - /// - /// If , it will default to one derived from the passed or , if possible - /// (e.g. via a on the method). - /// + /// + /// The description for the function. The default value is a description derived from the passed or , if possible + /// (for example, via a on the method). + /// public string? Description { get; set; } /// Gets or sets metadata for the parameters of the function. - /// - /// If , it will default to metadata derived from the passed or . - /// + /// + /// Metadata for the function's parameters. The default value is metadata derived from the passed or . + /// public IReadOnlyList? Parameters { get; set; } /// Gets or sets metadata for function's return parameter. - /// - /// If , it will default to one derived from the passed or . - /// + /// + /// Metadata for the function's return parameter. The default value is metadata derived from the passed or . + /// public AIFunctionReturnParameterMetadata? ReturnParameter { get; set; } /// - /// Gets or sets additional values that will be stored on the resulting property. + /// Gets or sets additional values to store on the resulting property. /// /// - /// This can be used to provide arbitrary information about the function. + /// This property can be used to provide arbitrary information about the function. /// public IReadOnlyDictionary? AdditionalProperties { get; set; } } From c163960f9ed0149863b1b8bcc8a45f18e159f5d1 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 11 Nov 2024 09:38:04 -0500 Subject: [PATCH 106/190] Use ToChatCompletion in OpenTelemetryChatClient (#5614) --- .../ChatCompletion/OpenTelemetryChatClient.cs | 52 +------------------ 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index 3913d33145c..7cf26e5944f 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -201,62 +201,12 @@ public override async IAsyncEnumerable CompleteSt } finally { - TraceCompletion(activity, requestModelId, ComposeStreamingUpdatesIntoChatCompletion(trackedUpdates), error, stopwatch); + TraceCompletion(activity, requestModelId, trackedUpdates.ToChatCompletion(), error, stopwatch); await responseEnumerator.DisposeAsync(); } } - /// Creates a from a collection of instances. - /// - /// This only propagates information that's later used by the telemetry. If additional information from the - /// is needed, this implementation should be updated to include it. - /// - private static ChatCompletion ComposeStreamingUpdatesIntoChatCompletion( - List updates) - { - // Group updates by choice index. - Dictionary> choices = []; - foreach (var update in updates) - { - if (!choices.TryGetValue(update.ChoiceIndex, out var choiceContents)) - { - choices[update.ChoiceIndex] = choiceContents = []; - } - - choiceContents.Add(update); - } - - // Add a ChatMessage for each choice. - string? id = null; - ChatFinishReason? finishReason = null; - string? modelId = null; - List messages = new(choices.Count); - foreach (var choice in choices.OrderBy(c => c.Key)) - { - ChatRole? role = null; - List items = []; - foreach (var update in choice.Value) - { - id ??= update.CompletionId; - finishReason ??= update.FinishReason; - role ??= update.Role; - items.AddRange(update.Contents); - modelId ??= update.ModelId; - } - - messages.Add(new ChatMessage(role ?? ChatRole.Assistant, items)); - } - - return new(messages) - { - CompletionId = id, - FinishReason = finishReason, - ModelId = modelId, - Usage = updates.SelectMany(c => c.Contents).OfType().LastOrDefault()?.Details, - }; - } - /// Creates an activity for a chat completion request, or returns null if not enabled. private Activity? CreateAndConfigureActivity(ChatOptions? options) { From 148e221539becfc485eff4b92fdde5b497612272 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 11 Nov 2024 10:12:10 -0500 Subject: [PATCH 107/190] Use ToChatCompletion / ToStreamingChatCompletionUpdates in CachingChatClient (#5616) * Use ToChatCompletion / ToStreamingChatCompletionUpdates in CachingChatClient Adds a ToStreamingChatCompletionUpdates method that's the counterpart to the recently added ToChatCompletion. Then uses both from CachingChatClient instead of its now bespoke coalescing implementation. When coalescing is enabled (the default), CachingChatClient caches everything as a ChatCompletion, rather than distinguishing streaming and non-streaming. * Address PR feedback --- .../ChatCompletion/ChatCompletion.cs | 49 ++++++ .../StreamingChatCompletionUpdate.cs | 33 +++- ...StreamingChatCompletionUpdateExtensions.cs | 20 ++- .../ChatCompletion/CachingChatClient.cs | 156 +++++------------- .../ChatCompletion/ChatCompletionTests.cs | 93 +++++++++++ ...mingChatCompletionUpdateExtensionsTests.cs | 20 +++ .../DistributedCachingChatClientTest.cs | 41 +++-- 7 files changed, 281 insertions(+), 131 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs index 89182e26165..2cebeb71c27 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs @@ -87,4 +87,53 @@ public ChatMessage Message /// public override string ToString() => Choices is { Count: > 0 } choices ? string.Join(Environment.NewLine, choices) : string.Empty; + + /// Creates an array of instances that represent this . + /// An array of instances that may be used to represent this . + public StreamingChatCompletionUpdate[] ToStreamingChatCompletionUpdates() + { + StreamingChatCompletionUpdate? extra = null; + if (AdditionalProperties is not null || Usage is not null) + { + extra = new StreamingChatCompletionUpdate + { + AdditionalProperties = AdditionalProperties + }; + + if (Usage is { } usage) + { + extra.Contents.Add(new UsageContent(usage)); + } + } + + int choicesCount = Choices.Count; + var updates = new StreamingChatCompletionUpdate[choicesCount + (extra is null ? 0 : 1)]; + + for (int choiceIndex = 0; choiceIndex < choicesCount; choiceIndex++) + { + ChatMessage choice = Choices[choiceIndex]; + updates[choiceIndex] = new StreamingChatCompletionUpdate + { + ChoiceIndex = choiceIndex, + + AdditionalProperties = choice.AdditionalProperties, + AuthorName = choice.AuthorName, + Contents = choice.Contents, + RawRepresentation = choice.RawRepresentation, + Role = choice.Role, + + CompletionId = CompletionId, + CreatedAt = CreatedAt, + FinishReason = FinishReason, + ModelId = ModelId + }; + } + + if (extra is not null) + { + updates[choicesCount] = extra; + } + + return updates; + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs index 278d875258a..f63381c5757 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs @@ -9,14 +9,35 @@ namespace Microsoft.Extensions.AI; -// Conceptually this combines the roles of ChatCompletion and ChatMessage in streaming output. -// For ease of consumption, it also flattens the nested structure you see on streaming chunks in -// the OpenAI/Gemini APIs, so instead of a dictionary of choices, each update represents a single -// choice (and hence has its own role, choice ID, etc.). - /// -/// Represents a single response chunk from an . +/// Represents a single streaming response chunk from an . /// +/// +/// +/// Conceptually, this combines the roles of and +/// in streaming output. For ease of consumption, it also flattens the nested structure you see on +/// streaming chunks in some AI service, so instead of a dictionary of choices, each update represents a +/// single choice (and hence has its own role, choice ID, etc.). +/// +/// +/// is so named because it represents streaming updates +/// to a single chat completion. As such, it is considered erroneous for multiple updates that are part +/// of the same completion to contain competing values. For example, some updates that are part of +/// the same completion may have a +/// value, and others may have a non- value, but all of those with a non- +/// value must have the same value (e.g. . It should never be the case, for example, +/// that one in a completion has a role of +/// while another has a role of "AI". +/// +/// +/// The relationship between and is +/// codified in the and +/// , which enable bidirectional conversions +/// between the two. Note, however, that the conversion may be slightly lossy, for example if multiple updates +/// all have different objects whereas there's +/// only one slot for such an object available in . +/// +/// public class StreamingChatCompletionUpdate { /// The completion update content items. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs index 05ac80dd682..928b9366a27 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Linq; #if NET using System.Runtime.InteropServices; #endif @@ -133,7 +134,22 @@ private static void ProcessUpdate(StreamingChatCompletionUpdate update, Dictiona /// The corresponding option value provided to or . private static void AddMessagesToCompletion(Dictionary messages, ChatCompletion completion, bool coalesceContent) { - foreach (var entry in messages) + if (messages.Count <= 1) + { + foreach (var entry in messages) + { + AddMessage(completion, coalesceContent, entry); + } + } + else + { + foreach (var entry in messages.OrderBy(entry => entry.Key)) + { + AddMessage(completion, coalesceContent, entry); + } + } + + static void AddMessage(ChatCompletion completion, bool coalesceContent, KeyValuePair entry) { if (entry.Value.Role == default) { @@ -154,6 +170,8 @@ private static void AddMessagesToCompletion(Dictionary message if (content is UsageContent c) { completion.Usage = c.Details; + entry.Value.Contents = entry.Value.Contents.ToList(); + _ = entry.Value.Contents.Remove(c); break; } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs index ad620346172..770ffa60cfc 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; -using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -48,13 +47,12 @@ public override async Task CompleteAsync(IList chat // concurrent callers might trigger duplicate requests, but that's acceptable. var cacheKey = GetCacheKey(false, chatMessages, options); - if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is ChatCompletion existing) + if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is not { } result) { - return existing; + result = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + await WriteCacheAsync(cacheKey, result, cancellationToken).ConfigureAwait(false); } - var result = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); - await WriteCacheAsync(cacheKey, result, cancellationToken).ConfigureAwait(false); return result; } @@ -64,127 +62,59 @@ public override async IAsyncEnumerable CompleteSt { _ = Throw.IfNull(chatMessages); - var cacheKey = GetCacheKey(true, chatMessages, options); - if (await ReadCacheStreamingAsync(cacheKey, cancellationToken).ConfigureAwait(false) is { } existingChunks) + if (CoalesceStreamingUpdates) { - // Yield all of the cached items. - foreach (var chunk in existingChunks) + // When coalescing updates, we cache non-streaming results coalesced from streaming ones. That means + // we make a streaming request, yielding those results, but then convert those into a non-streaming + // result and cache it. When we get a cache hit, we yield the non-streaming result as a streaming one. + + var cacheKey = GetCacheKey(true, chatMessages, options); + if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is { } chatCompletion) { - yield return chunk; + // Yield all of the cached items. + foreach (var chunk in chatCompletion.ToStreamingChatCompletionUpdates()) + { + yield return chunk; + } + } + else + { + // Yield and store all of the items. + List capturedItems = []; + await foreach (var chunk in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) + { + capturedItems.Add(chunk); + yield return chunk; + } + + // Write the captured items to the cache as a non-streaming result. + await WriteCacheAsync(cacheKey, capturedItems.ToChatCompletion(), cancellationToken).ConfigureAwait(false); } } else { - // Yield and store all of the items. - List capturedItems = []; - await foreach (var chunk in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) + var cacheKey = GetCacheKey(true, chatMessages, options); + if (await ReadCacheStreamingAsync(cacheKey, cancellationToken).ConfigureAwait(false) is { } existingChunks) { - capturedItems.Add(chunk); - yield return chunk; + // Yield all of the cached items. + foreach (var chunk in existingChunks) + { + yield return chunk; + } } - - // If the caching client is configured to coalesce streaming updates, do so now within the capturedItems list. - if (CoalesceStreamingUpdates) + else { - StringBuilder coalescedText = new(); - - // Iterate through all of the items in the list looking for contiguous items that can be coalesced. - for (int startInclusive = 0; startInclusive < capturedItems.Count; startInclusive++) + // Yield and store all of the items. + List capturedItems = []; + await foreach (var chunk in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) { - // If an item isn't generally coalescable, skip it. - StreamingChatCompletionUpdate update = capturedItems[startInclusive]; - if (update.ChoiceIndex != 0 || - update.Contents.Count != 1 || - update.Contents[0] is not TextContent textContent) - { - continue; - } - - // We found a coalescable item. Look for more contiguous items that are also coalescable with it. - int endExclusive = startInclusive + 1; - for (; endExclusive < capturedItems.Count; endExclusive++) - { - StreamingChatCompletionUpdate next = capturedItems[endExclusive]; - if (next.ChoiceIndex != 0 || - next.Contents.Count != 1 || - next.Contents[0] is not TextContent || - - // changing role or author would be really strange, but check anyway - (update.Role is not null && next.Role is not null && update.Role != next.Role) || - (update.AuthorName is not null && next.AuthorName is not null && update.AuthorName != next.AuthorName)) - { - break; - } - } - - // If we couldn't find anything to coalesce, there's nothing to do. - if (endExclusive - startInclusive <= 1) - { - continue; - } - - // We found a coalescable run of items. Create a new node to represent the run. We create a new one - // rather than reappropriating one of the existing ones so as not to mutate an item already yielded. - _ = coalescedText.Clear().Append(capturedItems[startInclusive].Text); - - TextContent coalescedContent = new(null) // will patch the text after examining all items in the run - { - AdditionalProperties = textContent.AdditionalProperties?.Clone(), - }; - - StreamingChatCompletionUpdate coalesced = new() - { - AdditionalProperties = update.AdditionalProperties?.Clone(), - AuthorName = update.AuthorName, - CompletionId = update.CompletionId, - Contents = [coalescedContent], - CreatedAt = update.CreatedAt, - FinishReason = update.FinishReason, - ModelId = update.ModelId, - Role = update.Role, - - // Explicitly don't include RawRepresentation. It's not applicable if one update ends up being used - // to represent multiple, and it won't be serialized anyway. - }; - - // Replace the starting node with the coalesced node. - capturedItems[startInclusive] = coalesced; - - // Now iterate through all the rest of the updates in the run, updating the coalesced node with relevant properties, - // and nulling out the nodes along the way. We do this rather than removing the entry in order to avoid an O(N^2) operation. - // We'll remove all the null entries at the end of the loop, using RemoveAll to do so, which can remove all of - // the nulls in a single O(N) pass. - for (int i = startInclusive + 1; i < endExclusive; i++) - { - // Grab the next item. - StreamingChatCompletionUpdate next = capturedItems[i]; - capturedItems[i] = null!; - - var nextContent = (TextContent)next.Contents[0]; - _ = coalescedText.Append(nextContent.Text); - - coalesced.AuthorName ??= next.AuthorName; - coalesced.CompletionId ??= next.CompletionId; - coalesced.CreatedAt ??= next.CreatedAt; - coalesced.FinishReason ??= next.FinishReason; - coalesced.ModelId ??= next.ModelId; - coalesced.Role ??= next.Role; - } - - // Complete the coalescing by patching the text of the coalesced node. - coalesced.Text = coalescedText.ToString(); - - // Jump to the last update in the run, so that when we loop around and bump ahead, - // we're at the next update just after the run. - startInclusive = endExclusive - 1; + capturedItems.Add(chunk); + yield return chunk; } - // Remove all of the null slots left over from the coalescing process. - _ = capturedItems.RemoveAll(u => u is null); + // Write the captured items to the cache. + await WriteCacheStreamingAsync(cacheKey, capturedItems, cancellationToken).ConfigureAwait(false); } - - // Write the captured items to the cache. - await WriteCacheStreamingAsync(cacheKey, capturedItems, cancellationToken).ConfigureAwait(false); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs index a695e686f6e..35184f3ee5a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs @@ -167,4 +167,97 @@ public void JsonSerialization_Roundtrips() Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); } + + [Fact] + public void ToStreamingChatCompletionUpdates_SingleChoice() + { + ChatCompletion completion = new(new ChatMessage(new ChatRole("customRole"), "Text")) + { + CompletionId = "12345", + ModelId = "someModel", + FinishReason = ChatFinishReason.ContentFilter, + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 }, + }; + + StreamingChatCompletionUpdate[] updates = completion.ToStreamingChatCompletionUpdates(); + Assert.NotNull(updates); + Assert.Equal(2, updates.Length); + + StreamingChatCompletionUpdate update0 = updates[0]; + Assert.Equal("12345", update0.CompletionId); + 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); + + StreamingChatCompletionUpdate update1 = updates[1]; + Assert.Equal("value1", update1.AdditionalProperties?["key1"]); + Assert.Equal(42, update1.AdditionalProperties?["key2"]); + } + + [Fact] + public void ToStreamingChatCompletionUpdates_MultiChoice() + { + ChatCompletion completion = new( + [ + new ChatMessage(ChatRole.Assistant, + [ + new TextContent("Hello, "), + new ImageContent("http://localhost/image.png"), + new TextContent("world!"), + ]) + { + AdditionalProperties = new() { ["choice1Key"] = "choice1Value" }, + }, + + new ChatMessage(ChatRole.System, + [ + new FunctionCallContent("call123", "name"), + new FunctionResultContent("call123", "name", 42), + ]) + { + AdditionalProperties = new() { ["choice2Key"] = "choice2Value" }, + }, + ]) + { + CompletionId = "12345", + ModelId = "someModel", + FinishReason = ChatFinishReason.ContentFilter, + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 }, + Usage = new UsageDetails { TotalTokenCount = 123 }, + }; + + StreamingChatCompletionUpdate[] updates = completion.ToStreamingChatCompletionUpdates(); + Assert.NotNull(updates); + Assert.Equal(3, updates.Length); + + StreamingChatCompletionUpdate update0 = updates[0]; + Assert.Equal("12345", update0.CompletionId); + 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("assistant", update0.Role?.Value); + Assert.Equal("Hello, ", Assert.IsType(update0.Contents[0]).Text); + Assert.IsType(update0.Contents[1]); + Assert.Equal("world!", Assert.IsType(update0.Contents[2]).Text); + Assert.Equal("choice1Value", update0.AdditionalProperties?["choice1Key"]); + + StreamingChatCompletionUpdate update1 = updates[1]; + Assert.Equal("12345", update1.CompletionId); + Assert.Equal("someModel", update1.ModelId); + Assert.Equal(ChatFinishReason.ContentFilter, update1.FinishReason); + Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update1.CreatedAt); + Assert.Equal("system", update1.Role?.Value); + Assert.IsType(update1.Contents[0]); + Assert.IsType(update1.Contents[1]); + Assert.Equal("choice2Value", update1.AdditionalProperties?["choice2Key"]); + + StreamingChatCompletionUpdate update2 = updates[2]; + Assert.Equal("value1", update2.AdditionalProperties?["key1"]); + Assert.Equal(42, update2.AdditionalProperties?["key2"]); + Assert.Equal(123, Assert.IsType(Assert.Single(update2.Contents)).Details.TotalTokenCount); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs index bb0f08325d5..33eca7dcaae 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateExtensionsTests.cs @@ -189,6 +189,26 @@ void AddGap() } } + [Fact] + public async Task ToChatCompletion_UsageContentExtractedFromContents() + { + StreamingChatCompletionUpdate[] updates = + { + new() { Text = "Hello, " }, + new() { Text = "world!" }, + new() { Contents = [new UsageContent(new() { TotalTokenCount = 42 })] }, + }; + + ChatCompletion completion = await YieldAsync(updates).ToChatCompletionAsync(); + + Assert.NotNull(completion); + + Assert.NotNull(completion.Usage); + Assert.Equal(42, completion.Usage.TotalTokenCount); + + Assert.Equal("Hello, world!", Assert.IsType(Assert.Single(completion.Message.Contents)).Text); + } + private static async IAsyncEnumerable YieldAsync(IEnumerable updates) { foreach (StreamingChatCompletionUpdate update in updates) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs index 7f6ca20915e..67e23ec495c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs @@ -214,19 +214,18 @@ public async Task StreamingCachesSuccessResultsAsync() // Verify that all the expected properties will round-trip through the cache, // even if this involves serialization - List expectedCompletion = + List actualCompletion = [ new() { Role = new ChatRole("fakeRole1"), - ChoiceIndex = 3, + ChoiceIndex = 1, AdditionalProperties = new() { ["a"] = "b" }, Contents = [new TextContent("Chunk1")] }, new() { Role = new ChatRole("fakeRole2"), - Text = "Chunk2", Contents = [ new FunctionCallContent("someCallId", "someFn", new Dictionary { ["arg1"] = "value1" }), @@ -235,13 +234,33 @@ public async Task StreamingCachesSuccessResultsAsync() } ]; + List expectedCachedCompletion = + [ + new() + { + Role = new ChatRole("fakeRole2"), + Contents = [new FunctionCallContent("someCallId", "someFn", new Dictionary { ["arg1"] = "value1" })], + }, + new() + { + Role = new ChatRole("fakeRole1"), + ChoiceIndex = 1, + AdditionalProperties = new() { ["a"] = "b" }, + Contents = [new TextContent("Chunk1")] + }, + new() + { + Contents = [new UsageContent(new() { InputTokenCount = 123, OutputTokenCount = 456, TotalTokenCount = 99999 })], + }, + ]; + var innerCallCount = 0; using var testClient = new TestChatClient { CompleteStreamingAsyncCallback = delegate { innerCallCount++; - return ToAsyncEnumerableAsync(expectedCompletion); + return ToAsyncEnumerableAsync(actualCompletion); } }; using var outer = new DistributedCachingChatClient(testClient, _storage) @@ -251,7 +270,7 @@ public async Task StreamingCachesSuccessResultsAsync() // Make the initial request and do a quick sanity check var result1 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]); - await AssertCompletionsEqualAsync(expectedCompletion, result1); + await AssertCompletionsEqualAsync(actualCompletion, result1); Assert.Equal(1, innerCallCount); // Act @@ -259,7 +278,7 @@ public async Task StreamingCachesSuccessResultsAsync() // Assert Assert.Equal(1, innerCallCount); - await AssertCompletionsEqualAsync(expectedCompletion, result2); + await AssertCompletionsEqualAsync(expectedCachedCompletion, result2); // Act/Assert 2: Cache misses do not return cached results await ToListAsync(outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some modified input")])); @@ -306,10 +325,11 @@ public async Task StreamingCoalescesConsecutiveTextChunksAsync(bool? coalesce) // Assert if (coalesce is null or true) { - Assert.Collection(await ToListAsync(result2), - c => Assert.Equal("This becomes one chunk", c.Text), - c => Assert.IsType(Assert.Single(c.Contents)), - c => Assert.Equal("... and this becomes another one.", c.Text)); + StreamingChatCompletionUpdate update = Assert.Single(await ToListAsync(result2)); + Assert.Collection(update.Contents, + c => Assert.Equal("This becomes one chunk", Assert.IsType(c).Text), + c => Assert.IsType(c), + c => Assert.Equal("... and this becomes another one.", Assert.IsType(c).Text)); } else { @@ -396,7 +416,6 @@ public async Task StreamingAllowsConcurrentCallsAsync() List expectedCompletion = [ new() { Role = ChatRole.Assistant, Text = "Chunk 1" }, - new() { Role = ChatRole.System, Text = "Chunk 2" }, ]; using var testClient = new TestChatClient { From 81847a8c8b3a1743b096b715da7c7397cfe65de2 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 11 Nov 2024 10:46:16 -0500 Subject: [PATCH 108/190] Add DebuggerDisplay for DataContent (#5618) --- .../Contents/DataContent.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs index 8eb0afea4d6..e677bdcf36b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -25,6 +25,7 @@ namespace Microsoft.Extensions.AI; /// a . In that case, a data URI will be constructed and returned. /// /// +[DebuggerDisplay("{DebuggerDisplay,nq}")] public class DataContent : AIContent { // Design note: @@ -193,4 +194,16 @@ public ReadOnlyMemory? Data return _data; } } + + /// Gets a string representing this instance to display in the debugger. + private string DebuggerDisplay + { + get + { + const int MaxLength = 80; + + string uri = Uri; + return uri.Length <= MaxLength ? uri : $"{uri.Substring(0, MaxLength)}..."; + } + } } From 002cdb70bf8cb084c540d7fa7cb0908a5e36c2c5 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 11 Nov 2024 10:55:20 -0600 Subject: [PATCH 109/190] Remove AI in Microsoft.Extensions.AI.AotCompatibility.TestApp --- .../Microsoft.Extensions.AotCompatibility.TestApp.csproj} | 0 .../Program.cs | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename test/Libraries/{Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj => Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj} (100%) rename test/Libraries/{Microsoft.Extensions.AI.AotCompatibility.TestApp => Microsoft.Extensions.AotCompatibility.TestApp}/Program.cs (100%) diff --git a/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj similarity index 100% rename from test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Microsoft.Extensions.AI.AotCompatibility.TestApp.csproj rename to test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj diff --git a/test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Program.cs b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Program.cs similarity index 100% rename from test/Libraries/Microsoft.Extensions.AI.AotCompatibility.TestApp/Program.cs rename to test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Program.cs From 86deeab76eaecd9f0cf8726a2836acd92d06e72f Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 11 Nov 2024 11:05:06 -0600 Subject: [PATCH 110/190] Clean up the AotCompatibility.TestApp - Remove unnecessary code in Program.cs by turning off ReferenceTrimmer - Make the project publishable without passing in a TFM by only targeting a single TFM --- ....Extensions.AotCompatibility.TestApp.csproj | 7 ++++++- .../Program.cs | 18 ------------------ 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index 183cd150937..24495361ffb 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -2,13 +2,18 @@ Exe - $(LatestTargetFramework) + $(LatestTargetFramework) + + true false true + + + diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Program.cs b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Program.cs index b518dfa7739..c8b0819a744 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Program.cs +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Program.cs @@ -1,22 +1,4 @@ // 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 S125 // Remove this commented out code - -using Microsoft.Extensions.AI; - -// Use types from each library. - -// Microsoft.Extensions.AI.Ollama -using var b = new OllamaChatClient("http://localhost:11434", "llama3.2"); - -// Microsoft.Extensions.AI.AzureAIInference -// using var a = new Azure.AI.Inference.ChatCompletionClient(new Uri("http://localhost"), new("apikey")); // uncomment once warnings in Azure.AI.Inference are addressed - -// Microsoft.Extensions.AI.OpenAI -// using var c = new OpenAI.OpenAIClient("apikey").AsChatClient("gpt-4o-mini"); // uncomment once warnings in OpenAI are addressed - -// Microsoft.Extensions.AI -AIFunctionFactory.Create(() => { }); - System.Console.WriteLine("Success!"); From d42d3e58c55e02134fb2c50379422794f2f17bb4 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 11 Nov 2024 13:43:55 -0500 Subject: [PATCH 111/190] Tweak ChatMessage/StreamingChatCompletionUpdate.ToString (#5617) * Tweak ChatMessage/StreamingChatCompletionUpdate.ToString Include all text rather than just the first text content. * Address PR feedback and fix / add tests --- .../ChatCompletion/ChatCompletion.cs | 23 +++++++++++-- .../ChatCompletion/ChatMessage.cs | 3 +- .../StreamingChatCompletionUpdate.cs | 3 +- .../ChatCompletion/ChatCompletionTests.cs | 34 +++++++++++++++++++ .../ChatCompletion/ChatMessageTests.cs | 7 ++-- .../StreamingChatCompletionUpdateTests.cs | 4 +-- 6 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs index 2cebeb71c27..689bca9c4da 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatCompletion.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Text; using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; @@ -85,8 +86,26 @@ public ChatMessage Message public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } /// - public override string ToString() => - Choices is { Count: > 0 } choices ? string.Join(Environment.NewLine, choices) : string.Empty; + public override string ToString() + { + if (Choices.Count == 1) + { + return Choices[0].ToString(); + } + + StringBuilder sb = new(); + for (int i = 0; i < Choices.Count; i++) + { + if (i > 0) + { + _ = sb.AppendLine().AppendLine(); + } + + _ = sb.Append("Choice ").Append(i).AppendLine(":").Append(Choices[i]); + } + + return sb.ToString(); + } /// Creates an array of instances that represent this . /// An array of instances that may be used to represent this . diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs index ccbc1cae97b..6370319704b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs @@ -95,5 +95,6 @@ public IList Contents public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } /// - public override string ToString() => Text ?? string.Empty; + public override string ToString() => + string.Concat(Contents.OfType()); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs index f63381c5757..9978e0f29b7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs @@ -116,5 +116,6 @@ public IList Contents public string? ModelId { get; set; } /// - public override string ToString() => Text ?? string.Empty; + public override string ToString() => + string.Concat(Contents.OfType()); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs index 35184f3ee5a..15134782bd7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatCompletionTests.cs @@ -168,6 +168,40 @@ public void JsonSerialization_Roundtrips() Assert.Equal("value", ((JsonElement)value!).GetString()); } + [Fact] + public void ToString_OneChoice_OutputsChatMessageToString() + { + ChatCompletion completion = new( + [ + new ChatMessage(ChatRole.Assistant, "This is a test." + Environment.NewLine + "It's multiple lines.") + ]); + + Assert.Equal(completion.Choices[0].Text, completion.ToString()); + } + + [Fact] + public void ToString_MultipleChoices_OutputsAllChoicesWithPrefix() + { + ChatCompletion completion = new( + [ + new ChatMessage(ChatRole.Assistant, "This is a test." + Environment.NewLine + "It's multiple lines."), + new ChatMessage(ChatRole.Assistant, "So is" + Environment.NewLine + " this."), + new ChatMessage(ChatRole.Assistant, "And this."), + ]); + + Assert.Equal( + "Choice 0:" + Environment.NewLine + + completion.Choices[0] + Environment.NewLine + Environment.NewLine + + + "Choice 1:" + Environment.NewLine + + completion.Choices[1] + Environment.NewLine + Environment.NewLine + + + "Choice 2:" + Environment.NewLine + + completion.Choices[2], + + completion.ToString()); + } + [Fact] public void ToStreamingChatCompletionUpdates_SingleChoice() { 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 31336e70674..d1325b89bb7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using Xunit; @@ -91,7 +92,7 @@ public void Constructor_RoleList_PropsRoundtrip(int messageCount) } Assert.Equal("text-0", message.Text); - Assert.Equal("text-0", message.ToString()); + Assert.Equal(string.Concat(Enumerable.Range(0, messageCount).Select(i => $"text-{i}")), message.ToString()); } Assert.Null(message.AuthorName); @@ -134,13 +135,13 @@ public void Text_GetSet_UsesFirstTextContent() TextContent textContent = Assert.IsType(message.Contents[3]); Assert.Equal("text-1", textContent.Text); Assert.Equal("text-1", message.Text); - Assert.Equal("text-1", message.ToString()); + Assert.Equal("text-1text-2", message.ToString()); message.Text = "text-3"; Assert.Equal("text-3", message.Text); Assert.Equal("text-3", message.Text); Assert.Same(textContent, message.Contents[3]); - Assert.Equal("text-3", message.ToString()); + Assert.Equal("text-3text-2", message.ToString()); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs index f90f799c6f9..a54ca225a98 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/StreamingChatCompletionUpdateTests.cs @@ -103,13 +103,13 @@ public void Text_GetSet_UsesFirstTextContent() TextContent textContent = Assert.IsType(update.Contents[3]); Assert.Equal("text-1", textContent.Text); Assert.Equal("text-1", update.Text); - Assert.Equal("text-1", update.ToString()); + Assert.Equal("text-1text-2", update.ToString()); update.Text = "text-3"; Assert.Equal("text-3", update.Text); Assert.Equal("text-3", update.Text); Assert.Same(textContent, update.Contents[3]); - Assert.Equal("text-3", update.ToString()); + Assert.Equal("text-3text-2", update.ToString()); } [Fact] From c847f9a6158cc71ea34afb8353143075f054797f Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 11 Nov 2024 13:45:26 -0600 Subject: [PATCH 112/190] Make dotnet test publish the TestApp and fail if there are any warnings/errors --- ...osoft.Extensions.AotCompatibility.TestApp.csproj | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index 24495361ffb..48e0c614c40 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -1,4 +1,5 @@ - + + Exe @@ -17,6 +18,7 @@ + @@ -28,4 +30,13 @@ + + + + + + + + From 10fcb7bbc980688ebedd27ace9f4e681191de27c Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 11 Nov 2024 14:44:16 -0600 Subject: [PATCH 113/190] Hook the Test target as well so the test runs in CI --- ...crosoft.Extensions.AotCompatibility.TestApp.csproj | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index 48e0c614c40..6df7aff391f 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -9,6 +9,8 @@ true false true + + Clean;$(PrepareForBuildDependsOn) @@ -32,11 +34,14 @@ - - - + + + + + + From 33e95b87cc1a9d0ab0f28f83823ee5f7d1bb6e03 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 11 Nov 2024 15:14:53 -0600 Subject: [PATCH 114/190] Move clean logic to target. Include the rest of the libraries in the repo --- ...Extensions.AotCompatibility.TestApp.csproj | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index 6df7aff391f..d0627d984f6 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -9,32 +9,35 @@ true false true - - Clean;$(PrepareForBuildDependsOn) - - - - - - - - - + + + + + + + + + + + + + + + + + From 9fbc27c38e55234a404bb223cf1b4f5f291f1df3 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 11 Nov 2024 15:52:35 -0600 Subject: [PATCH 115/190] - Add tracing issues. - Ensure clean uses the same configuration as publish - Fix part of Compliance.Redaction trimming issues by reenabling the Config Binder source generator. --- ...icrosoft.Extensions.Compliance.Redaction.csproj | 3 ++- ...soft.Extensions.AotCompatibility.TestApp.csproj | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Redaction/Microsoft.Extensions.Compliance.Redaction.csproj b/src/Libraries/Microsoft.Extensions.Compliance.Redaction/Microsoft.Extensions.Compliance.Redaction.csproj index 79fbecf8c1e..f762e3ceee9 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Redaction/Microsoft.Extensions.Compliance.Redaction.csproj +++ b/src/Libraries/Microsoft.Extensions.Compliance.Redaction/Microsoft.Extensions.Compliance.Redaction.csproj @@ -7,13 +7,14 @@ true + true true true true true true - + false $(NoWarn);IL2026 diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index d0627d984f6..4944cdc4d37 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -20,12 +20,12 @@ - - - + - - + + + + @@ -35,10 +35,10 @@ - - From 95a80ccd84ca68dfd49b0fb8aaff3832a323da6b Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Mon, 11 Nov 2024 21:54:33 +0000 Subject: [PATCH 116/190] Expose options for making schema generation conformant with the subset accepted by OpenAI. (#5619) * Expose options for making schema generation conformant with the subset accepted by OpenAI. * Uses the same set of defaults in all layers. --- .../Utilities/AIJsonSchemaCreateOptions.cs | 25 +++- .../Utilities/AIJsonUtilities.Schema.cs | 44 ++++++- .../Functions/AIFunctionFactory.cs | 13 +- .../AIFunctionFactoryCreateOptions.cs | 10 ++ .../Utilities/AIJsonUtilitiesTests.cs | 117 ++++++++++++++++-- .../Functions/AIFunctionFactoryTest.cs | 13 ++ 6 files changed, 198 insertions(+), 24 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs index 150673560df..2ce42c3e618 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs @@ -16,15 +16,36 @@ public sealed class AIJsonSchemaCreateOptions /// /// Gets a value indicating whether to include the type keyword in inferred schemas for .NET enums. /// - public bool IncludeTypeInEnumSchemas { get; init; } + public bool IncludeTypeInEnumSchemas { get; init; } = true; /// /// Gets a value indicating whether to generate schemas with the additionalProperties set to false for .NET objects. /// - public bool DisallowAdditionalProperties { get; init; } + public bool DisallowAdditionalProperties { get; init; } = true; /// /// Gets a value indicating whether to include the $schema keyword in inferred schemas. /// public bool IncludeSchemaKeyword { get; init; } + + /// + /// Gets a value indicating whether to mark all properties as required in the schema. + /// + public bool RequireAllProperties { get; init; } = true; + + /// + /// Gets a value indicating whether to filter keywords that are disallowed by certain AI vendors. + /// + /// + /// Filters a number of non-essential schema keywords that are not yet supported by some AI vendors. + /// These include: + /// + /// The "minLength", "maxLength", "pattern", and "format" keywords. + /// The "minimum", "maximum", and "multipleOf" keywords. + /// The "patternProperties", "unevaluatedProperties", "propertyNames", "minProperties", and "maxProperties" keywords. + /// The "unevaluatedItems", "contains", "minContains", "maxContains", "minItems", "maxItems", and "uniqueItems" keywords. + /// + /// See also https://platform.openai.com/docs/guides/structured-outputs#some-type-specific-keywords-are-not-yet-supported. + /// + public bool FilterDisallowedKeywords { get; init; } = true; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs index fa893450d0f..195cf062eb3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; #if !NET9_0_OR_GREATER @@ -30,7 +31,9 @@ object? DefaultValue, bool IncludeSchemaUri, bool DisallowAdditionalProperties, - bool IncludeTypeInEnumSchemas); + bool IncludeTypeInEnumSchemas, + bool RequireAllProperties, + bool FilterDisallowedKeywords); namespace Microsoft.Extensions.AI; @@ -52,6 +55,10 @@ public static partial class AIJsonUtilities /// Gets a JSON schema only accepting null values. private static readonly JsonElement _nullJsonSchema = ParseJsonElement("""{"type":"null"}"""u8); + // List of keywords used by JsonSchemaExporter but explicitly disallowed by some AI vendors. + // cf. https://platform.openai.com/docs/guides/structured-outputs#some-type-specific-keywords-are-not-yet-supported + private static readonly string[] _schemaKeywordsDisallowedByAIVendors = ["minLength", "maxLength", "pattern", "format"]; + /// /// Determines a JSON schema for the provided parameter metadata. /// @@ -122,7 +129,9 @@ public static JsonElement CreateParameterJsonSchema( defaultValue, IncludeSchemaUri: false, inferenceOptions.DisallowAdditionalProperties, - inferenceOptions.IncludeTypeInEnumSchemas); + inferenceOptions.IncludeTypeInEnumSchemas, + inferenceOptions.RequireAllProperties, + inferenceOptions.FilterDisallowedKeywords); return GetJsonSchemaCached(serializerOptions, key); } @@ -154,7 +163,9 @@ public static JsonElement CreateJsonSchema( defaultValue, inferenceOptions.IncludeSchemaKeyword, inferenceOptions.DisallowAdditionalProperties, - inferenceOptions.IncludeTypeInEnumSchemas); + inferenceOptions.IncludeTypeInEnumSchemas, + inferenceOptions.RequireAllProperties, + inferenceOptions.FilterDisallowedKeywords); return GetJsonSchemaCached(serializerOptions, key); } @@ -242,6 +253,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) const string PatternPropertyName = "pattern"; const string EnumPropertyName = "enum"; const string PropertiesPropertyName = "properties"; + const string RequiredPropertyName = "required"; const string AdditionalPropertiesPropertyName = "additionalProperties"; const string DefaultPropertyName = "default"; const string RefPropertyName = "$ref"; @@ -275,11 +287,35 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) } // Disallow additional properties in object schemas - if (key.DisallowAdditionalProperties && objSchema.ContainsKey(PropertiesPropertyName) && !objSchema.ContainsKey(AdditionalPropertiesPropertyName)) + if (key.DisallowAdditionalProperties && + objSchema.ContainsKey(PropertiesPropertyName) && + !objSchema.ContainsKey(AdditionalPropertiesPropertyName)) { objSchema.Add(AdditionalPropertiesPropertyName, (JsonNode)false); } + // Mark all properties as required + if (key.RequireAllProperties && + objSchema.TryGetPropertyValue(PropertiesPropertyName, out JsonNode? properties) && + properties is JsonObject propertiesObj) + { + _ = objSchema.TryGetPropertyValue(RequiredPropertyName, out JsonNode? required); + if (required is not JsonArray { } requiredArray || requiredArray.Count != propertiesObj.Count) + { + requiredArray = [.. propertiesObj.Select(prop => prop.Key)]; + objSchema[RequiredPropertyName] = requiredArray; + } + } + + // Filter potentially disallowed keywords. + if (key.FilterDisallowedKeywords) + { + foreach (string keyword in _schemaKeywordsDisallowedByAIVendors) + { + _ = objSchema.Remove(keyword); + } + } + // 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. // STJ represents .NET integer types as ["string", "integer"], which will then lead to an error. diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index a19608a2977..09d55388f75 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -189,7 +189,7 @@ static bool IsAsyncMethod(MethodInfo method) bool sawAIContextParameter = false; for (int i = 0; i < parameters.Length; i++) { - if (GetParameterMarshaller(options.SerializerOptions, parameters[i], ref sawAIContextParameter, out _parameterMarshallers[i]) is AIFunctionParameterMetadata parameterView) + if (GetParameterMarshaller(options, parameters[i], ref sawAIContextParameter, out _parameterMarshallers[i]) is AIFunctionParameterMetadata parameterView) { parameterMetadata?.Add(parameterView); } @@ -209,7 +209,7 @@ static bool IsAsyncMethod(MethodInfo method) { ParameterType = returnType, Description = method.ReturnParameter.GetCustomAttribute(inherit: true)?.Description, - Schema = AIJsonUtilities.CreateJsonSchema(returnType, serializerOptions: options.SerializerOptions), + Schema = AIJsonUtilities.CreateJsonSchema(returnType, serializerOptions: options.SerializerOptions, inferenceOptions: options.SchemaCreateOptions), }, AdditionalProperties = options.AdditionalProperties ?? EmptyReadOnlyDictionary.Instance, JsonSerializerOptions = options.SerializerOptions, @@ -272,7 +272,7 @@ static bool IsAsyncMethod(MethodInfo method) /// Gets a delegate for handling the marshaling of a parameter. /// private static AIFunctionParameterMetadata? GetParameterMarshaller( - JsonSerializerOptions options, + AIFunctionFactoryCreateOptions options, ParameterInfo parameter, ref bool sawAIFunctionContext, out Func, AIFunctionContext?, object?> marshaller) @@ -302,7 +302,7 @@ static bool IsAsyncMethod(MethodInfo method) // Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found. Type parameterType = parameter.ParameterType; - JsonTypeInfo typeInfo = options.GetTypeInfo(parameterType); + JsonTypeInfo typeInfo = options.SerializerOptions.GetTypeInfo(parameterType); // Create a marshaller that simply looks up the parameter by name in the arguments dictionary. marshaller = (IReadOnlyDictionary arguments, AIFunctionContext? _) => @@ -325,7 +325,7 @@ static bool IsAsyncMethod(MethodInfo method) #pragma warning disable CA1031 // Do not catch general exception types try { - string json = JsonSerializer.Serialize(value, options.GetTypeInfo(value.GetType())); + string json = JsonSerializer.Serialize(value, options.SerializerOptions.GetTypeInfo(value.GetType())); return JsonSerializer.Deserialize(json, typeInfo); } catch @@ -361,7 +361,8 @@ static bool IsAsyncMethod(MethodInfo method) description, parameter.HasDefaultValue, parameter.DefaultValue, - options) + options.SerializerOptions, + options.SchemaCreateOptions) }; } diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs index 6483b83c0ad..1f33c6d4155 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryCreateOptions.cs @@ -16,6 +16,7 @@ namespace Microsoft.Extensions.AI; public sealed class AIFunctionFactoryCreateOptions { private JsonSerializerOptions _options = AIJsonUtilities.DefaultOptions; + private AIJsonSchemaCreateOptions _schemaCreateOptions = AIJsonSchemaCreateOptions.Default; /// /// Initializes a new instance of the class. @@ -31,6 +32,15 @@ public JsonSerializerOptions SerializerOptions set => _options = Throw.IfNull(value); } + /// + /// Gets or sets the governing the generation of JSON schemas for the function. + /// + public AIJsonSchemaCreateOptions SchemaCreateOptions + { + get => _schemaCreateOptions; + set => _schemaCreateOptions = Throw.IfNull(value); + } + /// Gets or sets the name to use for the function. /// /// The name to use for the function. The default value is a name derived from the method represented by the passed or . 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 52f9cad246d..4107618d85b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -1,10 +1,13 @@ // 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.Linq; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.AI.JsonSchemaExporter; using Xunit; @@ -38,9 +41,11 @@ public static void DefaultOptions_HasExpectedConfiguration() public static void AIJsonSchemaCreateOptions_DefaultInstance_ReturnsExpectedValues(bool useSingleton) { AIJsonSchemaCreateOptions options = useSingleton ? AIJsonSchemaCreateOptions.Default : new AIJsonSchemaCreateOptions(); - Assert.False(options.IncludeTypeInEnumSchemas); - Assert.False(options.DisallowAdditionalProperties); + Assert.True(options.IncludeTypeInEnumSchemas); + Assert.True(options.DisallowAdditionalProperties); Assert.False(options.IncludeSchemaKeyword); + Assert.True(options.RequireAllProperties); + Assert.True(options.FilterDisallowedKeywords); } [Fact] @@ -56,6 +61,7 @@ public static void CreateJsonSchema_DefaultParameters_GeneratesExpectedJsonSchem "type": "integer" }, "EnumValue": { + "type": "string", "enum": ["A", "B"] }, "Value": { @@ -63,11 +69,13 @@ public static void CreateJsonSchema_DefaultParameters_GeneratesExpectedJsonSchem "default": null } }, - "required": ["Key", "EnumValue"] + "required": ["Key", "EnumValue", "Value"], + "additionalProperties": false } """).RootElement; JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco), serializerOptions: JsonSerializerOptions.Default); + Assert.True(JsonElement.DeepEquals(expected, actual)); } @@ -85,7 +93,6 @@ public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSc "type": "integer" }, "EnumValue": { - "type": "string", "enum": ["A", "B"] }, "Value": { @@ -94,28 +101,109 @@ public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSc } }, "required": ["Key", "EnumValue"], - "additionalProperties": false, "default": "42" } """).RootElement; AIJsonSchemaCreateOptions inferenceOptions = new AIJsonSchemaCreateOptions { - IncludeTypeInEnumSchemas = true, - DisallowAdditionalProperties = true, - IncludeSchemaKeyword = true + IncludeTypeInEnumSchemas = false, + DisallowAdditionalProperties = false, + IncludeSchemaKeyword = true, + RequireAllProperties = false, }; - JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco), + JsonElement actual = AIJsonUtilities.CreateJsonSchema( + typeof(MyPoco), description: "alternative description", hasDefaultValue: true, defaultValue: 42, - JsonSerializerOptions.Default, - inferenceOptions); + serializerOptions: JsonSerializerOptions.Default, + inferenceOptions: inferenceOptions); Assert.True(JsonElement.DeepEquals(expected, actual)); } + [Fact] + public static void CreateJsonSchema_FiltersDisallowedKeywords() + { + JsonElement expected = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "Date": { + "type": "string" + }, + "TimeSpan": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string" + }, + "Char" : { + "type": "string" + } + }, + "required": ["Date","TimeSpan","Char"], + "additionalProperties": false + } + """).RootElement; + + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords), serializerOptions: JsonSerializerOptions.Default); + + Assert.True(JsonElement.DeepEquals(expected, actual)); + } + + [Fact] + public static void CreateJsonSchema_FilterDisallowedKeywords_Disabled() + { + JsonElement expected = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "Date": { + "type": "string", + "format": "date-time" + }, + "TimeSpan": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "Char" : { + "type": "string", + "minLength": 1, + "maxLength": 1 + } + }, + "required": ["Date","TimeSpan","Char"], + "additionalProperties": false + } + """).RootElement; + + AIJsonSchemaCreateOptions inferenceOptions = new() + { + FilterDisallowedKeywords = false + }; + + JsonElement actual = AIJsonUtilities.CreateJsonSchema( + typeof(PocoWithTypesWithOpenAIUnsupportedKeywords), + serializerOptions: JsonSerializerOptions.Default, + inferenceOptions: inferenceOptions); + + Assert.True(JsonElement.DeepEquals(expected, actual)); + } + + public class PocoWithTypesWithOpenAIUnsupportedKeywords + { + // Uses the unsupported "format" keyword + public DateTimeOffset Date { get; init; } + + // Uses the unsupported "pattern" keyword + public TimeSpan TimeSpan { get; init; } + + // Uses the unsupported "minLength" and "maxLength" keywords + public char Char { get; init; } + } + [Fact] public static void ResolveParameterJsonSchema_ReturnsExpectedValue() { @@ -178,7 +266,12 @@ public static void CreateJsonSchema_ValidateWithTestData(ITestData testData) ? new(opts) { TypeInfoResolver = TestTypes.TestTypesContext.Default } : TestTypes.TestTypesContext.Default.Options; - JsonElement schema = AIJsonUtilities.CreateJsonSchema(testData.Type, serializerOptions: options); + JsonTypeInfo typeInfo = options.GetTypeInfo(testData.Type); + AIJsonSchemaCreateOptions? createOptions = typeInfo.Properties.Any(prop => prop.IsExtensionData) + ? new() { DisallowAdditionalProperties = false } // Do not append additionalProperties: false to the schema if the type has extension data. + : null; + + JsonElement schema = AIJsonUtilities.CreateJsonSchema(testData.Type, serializerOptions: options, inferenceOptions: createOptions); JsonNode? schemaAsNode = JsonSerializer.SerializeToNode(schema, options); Assert.NotNull(schemaAsNode); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 7d8b10814d4..0bec845babc 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -182,4 +182,17 @@ public void AIFunctionFactoryCreateOptions_ValuesPropagateToAIFunction() Assert.Equal(returnParameterMetadata, func.Metadata.ReturnParameter); Assert.Equal(metadata, func.Metadata.AdditionalProperties); } + + [Fact] + public void AIFunctionFactoryCreateOptions_SchemaOptions_HasExpectedDefaults() + { + var options = new AIFunctionFactoryCreateOptions(); + var schemaOptions = options.SchemaCreateOptions; + + Assert.NotNull(schemaOptions); + Assert.True(schemaOptions.IncludeTypeInEnumSchemas); + Assert.True(schemaOptions.FilterDisallowedKeywords); + Assert.True(schemaOptions.RequireAllProperties); + Assert.True(schemaOptions.DisallowAdditionalProperties); + } } From fbf2866b346aa18ee5ab8cee9ef0c1b2eb072bd4 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 11 Nov 2024 16:53:21 -0600 Subject: [PATCH 117/190] Add tracking issue links for OpenAI and Azure.AI.Inference trim/AOT compatibility. --- .../Microsoft.Extensions.AotCompatibility.TestApp.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index 4944cdc4d37..e82e071561a 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -16,9 +16,9 @@ - + - + From ff0bf8ca5030f8c1145f7a3b53391a093d92ddb8 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 11 Nov 2024 18:15:40 -0600 Subject: [PATCH 118/190] Add an exclusion for Microsoft.Extensions.AI.Abstractions --- .../Microsoft.Extensions.AotCompatibility.TestApp.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index e82e071561a..fdf1115e8f9 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -16,6 +16,8 @@ + + From 12b894951296c6eecf0b8f3be9bea1318eba9dee Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 11 Nov 2024 21:56:23 -0600 Subject: [PATCH 119/190] Exclude Microsoft.Extensions.AI as well, since it hits the warnings in #5626 --- .../Microsoft.Extensions.AotCompatibility.TestApp.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index fdf1115e8f9..27c8608d61d 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -18,6 +18,7 @@ + From 4b8dad55879eeb4b5c5baedc194c94f4250016d9 Mon Sep 17 00:00:00 2001 From: Haipz Date: Tue, 12 Nov 2024 15:46:47 +0800 Subject: [PATCH 120/190] Cache current process object to avoid performance hit (#5597) * Read working set from Environment in ProcessInfo since it has better performance. * Add unit test for ProcessInfo. * Remove OSSkipCondition tag from process info unit test since it's cross-platform. * Use Environment.WorkingSet in GetMemoryUsageInBytes. --- .../Windows/Interop/ProcessInfo.cs | 4 ++-- .../Windows/WindowsSnapshotProvider.cs | 3 +-- .../Windows/ProcessInfoTests.cs | 23 +++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/ProcessInfoTests.cs diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs index cb5febeff55..fb5223f3d02 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.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; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -41,7 +42,6 @@ public ulong GetMemoryUsage() public ulong GetCurrentProcessMemoryUsage() { - using Process process = Process.GetCurrentProcess(); - return (ulong)process.WorkingSet64; + return (ulong)Environment.WorkingSet; } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs index 7197499afd9..da828a2d064 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs @@ -109,8 +109,7 @@ internal static long GetCpuTicks() internal static long GetMemoryUsageInBytes() { - using var process = Process.GetCurrentProcess(); - return process.WorkingSet64; + return Environment.WorkingSet; } internal static ulong GetTotalMemoryInBytes() diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/ProcessInfoTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/ProcessInfoTests.cs new file mode 100644 index 00000000000..ab83f2677df --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/ProcessInfoTests.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 Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Interop; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Test; + +/// +/// Process Info Interop Tests. +/// +/// These tests are added for coverage reasons, but the code doesn't have +/// the necessary environment predictability to really test it. +public sealed class ProcessInfoTests +{ + [ConditionalFact] + public void GetCurrentProcessMemoryUsage() + { + var workingSet64 = new ProcessInfo().GetCurrentProcessMemoryUsage(); + Assert.True(workingSet64 > 0); + } +} From c77e368808f613cca0f94606a37c847571277797 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 12 Nov 2024 11:02:32 -0500 Subject: [PATCH 121/190] Fix namespace for IServiceCollection extensions (#5620) We generally put extension methods into the same namespace as the thing they're extending. --- .../ChatClientBuilderServiceCollectionExtensions.cs | 4 ++-- .../EmbeddingGeneratorBuilderServiceCollectionExtensions.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs index 246ac7f3689..9d419f434af 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs @@ -2,10 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Extensions.AI; +namespace Microsoft.Extensions.DependencyInjection; /// Provides extension methods for registering with a . public static class ChatClientBuilderServiceCollectionExtensions diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs index 369de130e72..4f2eddf6b1b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs @@ -2,10 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Extensions.AI; +namespace Microsoft.Extensions.DependencyInjection; /// Provides extension methods for registering with a . public static class EmbeddingGeneratorBuilderServiceCollectionExtensions From 7d554db60ca7357b9fcd6bebdf114878224b7b2b Mon Sep 17 00:00:00 2001 From: Haipz Date: Tue, 12 Nov 2024 15:46:47 +0800 Subject: [PATCH 122/190] Cache current process object to avoid performance hit (#5597) * Read working set from Environment in ProcessInfo since it has better performance. * Add unit test for ProcessInfo. * Remove OSSkipCondition tag from process info unit test since it's cross-platform. * Use Environment.WorkingSet in GetMemoryUsageInBytes. --- .../Windows/Interop/ProcessInfo.cs | 4 ++-- .../Windows/WindowsSnapshotProvider.cs | 3 +-- .../Windows/ProcessInfoTests.cs | 23 +++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/ProcessInfoTests.cs diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs index cb5febeff55..fb5223f3d02 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.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; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -41,7 +42,6 @@ public ulong GetMemoryUsage() public ulong GetCurrentProcessMemoryUsage() { - using Process process = Process.GetCurrentProcess(); - return (ulong)process.WorkingSet64; + return (ulong)Environment.WorkingSet; } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs index 7197499afd9..da828a2d064 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs @@ -109,8 +109,7 @@ internal static long GetCpuTicks() internal static long GetMemoryUsageInBytes() { - using var process = Process.GetCurrentProcess(); - return process.WorkingSet64; + return Environment.WorkingSet; } internal static ulong GetTotalMemoryInBytes() diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/ProcessInfoTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/ProcessInfoTests.cs new file mode 100644 index 00000000000..ab83f2677df --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/ProcessInfoTests.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 Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Interop; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Test; + +/// +/// Process Info Interop Tests. +/// +/// These tests are added for coverage reasons, but the code doesn't have +/// the necessary environment predictability to really test it. +public sealed class ProcessInfoTests +{ + [ConditionalFact] + public void GetCurrentProcessMemoryUsage() + { + var workingSet64 = new ProcessInfo().GetCurrentProcessMemoryUsage(); + Assert.True(workingSet64 > 0); + } +} From 6487428046a2d7a5c8bb6034e97c38d6e0b7affa Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 12 Nov 2024 16:37:57 +0000 Subject: [PATCH 123/190] Fix linker warning. (#5627) --- .../Utilities/AIJsonUtilities.Schema.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs index 195cf062eb3..4e3f90aa47f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs @@ -302,7 +302,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) _ = objSchema.TryGetPropertyValue(RequiredPropertyName, out JsonNode? required); if (required is not JsonArray { } requiredArray || requiredArray.Count != propertiesObj.Count) { - requiredArray = [.. propertiesObj.Select(prop => prop.Key)]; + requiredArray = [.. propertiesObj.Select(prop => (JsonNode)prop.Key)]; objSchema[RequiredPropertyName] = requiredArray; } } From 9c675f19132a687f45af6a29ad1e63d52f6c4cf2 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 12 Nov 2024 10:58:04 -0600 Subject: [PATCH 124/190] Remove cleaning from publish --- .../Microsoft.Extensions.AotCompatibility.TestApp.csproj | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index 27c8608d61d..d2bf9f55080 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -37,11 +37,7 @@ - - - - From 5b339698afc3087b1c11648ca14d04585725f93b Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 12 Nov 2024 11:09:16 -0600 Subject: [PATCH 125/190] Remove MS.Ext.AI now that the issue is resolved. --- .../Microsoft.Extensions.AotCompatibility.TestApp.csproj | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index d2bf9f55080..3f7270570c2 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -16,9 +16,6 @@ - - - From 47beb96689bfa1ed4651c7bfacc42e8ece8dedc5 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 12 Nov 2024 14:10:49 -0600 Subject: [PATCH 126/190] Temporarily disable the test to figure out what is causing the code coverage errors. --- .../Microsoft.Extensions.AotCompatibility.TestApp.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index 3f7270570c2..dcd6f638232 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -40,7 +40,7 @@ - - + From 0a4bbeed0eb451ac6484c8bdcf25eb3495f67637 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 12 Nov 2024 15:53:09 -0600 Subject: [PATCH 127/190] Revert "Temporarily disable the test to figure out what is causing the code coverage errors." This reverts commit 47beb96689bfa1ed4651c7bfacc42e8ece8dedc5. --- .../Microsoft.Extensions.AotCompatibility.TestApp.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index dcd6f638232..3f7270570c2 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -40,7 +40,7 @@ - + + From e690bbf4b7e88a8d57925a57f591eab132985069 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 12 Nov 2024 16:00:54 -0600 Subject: [PATCH 128/190] try running during BuildAndTest.yml --- eng/pipelines/templates/BuildAndTest.yml | 4 ++++ .../Microsoft.Extensions.AotCompatibility.TestApp.csproj | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index ced3ce0afb3..e3ec5bcd7dd 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -68,6 +68,10 @@ steps: condition: always() continueOnError: true + - script: $(Build.SourcesDirectory)/.dotnet/dotnet publish + workingDirectory: $(Build.SourcesDirectory)/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp + displayName: Publish AOT Test + - ${{ if ne(parameters.skipQualityGates, 'true') }}: - ${{ if eq(parameters.runAsPublic, 'true') }}: - task: PublishPipelineArtifact@1 diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index 3f7270570c2..dd60f67d060 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -25,7 +25,7 @@ - + @@ -40,7 +40,7 @@ - - + From 30dae3db7a269936a36e78da66487eae579856ba Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 12 Nov 2024 16:46:21 -0600 Subject: [PATCH 129/190] Remove testing cruft from TestApp project --- ...t.Extensions.AotCompatibility.TestApp.csproj | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index dd60f67d060..59b896ccf8a 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -1,5 +1,4 @@ - - + Exe @@ -25,22 +24,10 @@ - + - - - - - - - - - - From 4cd0228eeb526e4bebc2b132c97653aeffed80a9 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 12 Nov 2024 18:17:16 -0600 Subject: [PATCH 130/190] Change TrimmerRootAssembly to use FileName to be correct. --- .../Microsoft.Extensions.AotCompatibility.TestApp.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj index 59b896ccf8a..07bf93e044c 100644 --- a/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj +++ b/test/Libraries/Microsoft.Extensions.AotCompatibility.TestApp/Microsoft.Extensions.AotCompatibility.TestApp.csproj @@ -26,7 +26,7 @@ - + From 73962c60f1ba41c93a6ba7298a43de18f6f77676 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 13 Nov 2024 18:35:39 +0000 Subject: [PATCH 131/190] Replace STJ boilerplate in the leaf clients with AIJsonUtilities calls. (#5630) * Replace STJ boilerplate in the leaf clients with AIJsonUtilities calls. * Address feedback. * Address feedback. * Remove redundant using --- .../AzureAIInferenceChatClient.cs | 21 ++++-- .../AzureAIInferenceEmbeddingGenerator.cs | 2 +- .../JsonContext.cs | 57 +--------------- .../JsonContext.cs | 4 -- .../OllamaChatClient.cs | 15 +++-- .../OpenAIChatClient.cs | 65 +++++-------------- .../AzureAIInferenceChatClientTests.cs | 14 ++++ .../OllamaChatClientTests.cs | 14 ++++ .../OpenAIChatClientTests.cs | 14 ++++ 9 files changed, 84 insertions(+), 122 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 5c4e630da1a..7a4d24abd5e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; using Azure.AI.Inference; @@ -27,6 +28,9 @@ public sealed class AzureAIInferenceChatClient : IChatClient /// The underlying . private readonly ChatCompletionsClient _chatCompletionsClient; + /// 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 for the specified . /// The underlying client. /// The ID of the model to use. If null, it can be provided per request via . @@ -51,7 +55,11 @@ public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, s } /// Gets or sets to use for any serialization activities related to tool call arguments and results. - public JsonSerializerOptions? ToolCallJsonSerializerOptions { get; set; } + public JsonSerializerOptions ToolCallJsonSerializerOptions + { + get => _toolCallJsonSerializerOptions; + set => _toolCallJsonSerializerOptions = Throw.IfNull(value); + } /// public ChatClientMetadata Metadata { get; } @@ -304,7 +312,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, // These properties are strongly typed on ChatOptions but not on ChatCompletionsOptions. if (options.TopK is int topK) { - result.AdditionalProperties["top_k"] = new BinaryData(JsonSerializer.SerializeToUtf8Bytes(topK, JsonContext.Default.Int32)); + result.AdditionalProperties["top_k"] = new BinaryData(JsonSerializer.SerializeToUtf8Bytes(topK, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(int)))); } if (options.AdditionalProperties is { } props) @@ -317,7 +325,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IList chatContents, default: if (prop.Value is not null) { - byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, JsonContext.GetTypeInfo(prop.Value.GetType(), ToolCallJsonSerializerOptions)); + byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); result.AdditionalProperties[prop.Key] = new BinaryData(data); } @@ -419,7 +427,7 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab { try { - result = JsonSerializer.Serialize(resultContent.Result, JsonContext.GetTypeInfo(typeof(object), ToolCallJsonSerializerOptions)); + result = JsonSerializer.Serialize(resultContent.Result, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -449,7 +457,7 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab callRequest.CallId, new FunctionCall( callRequest.Name, - JsonSerializer.Serialize(callRequest.Arguments, JsonContext.GetTypeInfo(typeof(IDictionary), ToolCallJsonSerializerOptions))))); + JsonSerializer.Serialize(callRequest.Arguments, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(IDictionary)))))); } } @@ -490,5 +498,6 @@ private static List GetContentParts(IList con private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); + argumentParser: static json => JsonSerializer.Deserialize(json, + (JsonTypeInfo>)AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))!); } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs index 0c785cbbd6d..295b45627e8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs @@ -173,7 +173,7 @@ private EmbeddingsOptions ToAzureAIOptions(IEnumerable inputs, Embedding { if (prop.Value is not null) { - byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, JsonContext.GetTypeInfo(prop.Value.GetType(), null)); + byte[] data = JsonSerializer.SerializeToUtf8Bytes(prop.Value, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); result.AdditionalProperties[prop.Key] = new BinaryData(data); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs index 1e1dabffab7..89e0946d306 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/JsonContext.cs @@ -1,12 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; namespace Microsoft.Extensions.AI; @@ -16,55 +12,4 @@ namespace Microsoft.Extensions.AI; DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true)] [JsonSerializable(typeof(AzureAIChatToolJson))] -[JsonSerializable(typeof(IDictionary))] -[JsonSerializable(typeof(JsonElement))] -[JsonSerializable(typeof(int))] -[JsonSerializable(typeof(long))] -[JsonSerializable(typeof(float))] -[JsonSerializable(typeof(double))] -[JsonSerializable(typeof(bool))] -[JsonSerializable(typeof(float[]))] -[JsonSerializable(typeof(byte[]))] -[JsonSerializable(typeof(sbyte[]))] -internal sealed partial class JsonContext : JsonSerializerContext -{ - /// Gets the singleton used as the default in JSON serialization operations. - private static readonly JsonSerializerOptions _defaultToolJsonOptions = CreateDefaultToolJsonOptions(); - - /// Gets JSON type information for the specified type. - /// - /// This first tries to get the type information from , - /// falling back to if it can't. - /// - public static JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions? firstOptions) => - firstOptions?.TryGetTypeInfo(type, out JsonTypeInfo? info) is true ? - info : - _defaultToolJsonOptions.GetTypeInfo(type); - - /// Creates the default to use for serialization-related operations. - [UnconditionalSuppressMessage("AotAnalysis", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] - private static JsonSerializerOptions CreateDefaultToolJsonOptions() - { - // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, - // and we want to be flexible in terms of what can be put into the various collections in the object model. - // Otherwise, use the source-generated options to enable trimming and Native AOT. - - if (JsonSerializer.IsReflectionEnabledByDefault) - { - // Keep in sync with the JsonSourceGenerationOptions attribute on JsonContext above. - JsonSerializerOptions options = new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = new DefaultJsonTypeInfoResolver(), - Converters = { new JsonStringEnumConverter() }, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true, - }; - - options.MakeReadOnly(); - return options; - } - - return Default.Options; - } -} +internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs index b90a28abb51..6de0144c7cf 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.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.Collections.Generic; -using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI; @@ -23,6 +21,4 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(OllamaToolCall))] [JsonSerializable(typeof(OllamaEmbeddingRequest))] [JsonSerializable(typeof(OllamaEmbeddingResponse))] -[JsonSerializable(typeof(IDictionary))] -[JsonSerializable(typeof(JsonElement))] internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 780b334cd93..abfa3f2b203 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -30,6 +30,9 @@ public sealed class OllamaChatClient : IChatClient /// 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. /// @@ -66,7 +69,11 @@ public OllamaChatClient(Uri endpoint, string? modelId = null, HttpClient? httpCl public ChatClientMetadata Metadata { get; } /// Gets or sets to use for any serialization activities related to tool call arguments and results. - public JsonSerializerOptions? ToolCallJsonSerializerOptions { get; set; } + public JsonSerializerOptions ToolCallJsonSerializerOptions + { + get => _toolCallJsonSerializerOptions; + set => _toolCallJsonSerializerOptions = Throw.IfNull(value); + } /// public async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) @@ -388,7 +395,6 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe case FunctionCallContent fcc: { - JsonSerializerOptions serializerOptions = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; yield return new OllamaChatRequestMessage { Role = "assistant", @@ -396,7 +402,7 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe { CallId = fcc.CallId, Name = fcc.Name, - Arguments = JsonSerializer.SerializeToElement(fcc.Arguments, serializerOptions.GetTypeInfo(typeof(IDictionary))), + Arguments = JsonSerializer.SerializeToElement(fcc.Arguments, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(IDictionary))), }, JsonContext.Default.OllamaFunctionCallContent) }; break; @@ -404,8 +410,7 @@ private IEnumerable ToOllamaChatRequestMessages(ChatMe case FunctionResultContent frc: { - JsonSerializerOptions serializerOptions = ToolCallJsonSerializerOptions ?? JsonContext.Default.Options; - JsonElement jsonResult = JsonSerializer.SerializeToElement(frc.Result, serializerOptions.GetTypeInfo(typeof(object))); + JsonElement jsonResult = JsonSerializer.SerializeToElement(frc.Result, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); yield return new OllamaChatRequestMessage { Role = "tool", diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 6e4a8d8ec9b..90329a9b593 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -38,6 +37,9 @@ public sealed partial class OpenAIChatClient : IChatClient /// The underlying . private readonly ChatClient _chatClient; + /// 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 for the specified . /// The underlying client. /// The model to use. @@ -80,7 +82,11 @@ public OpenAIChatClient(ChatClient chatClient) } /// Gets or sets to use for any serialization activities related to tool call arguments and results. - public JsonSerializerOptions? ToolCallJsonSerializerOptions { get; set; } + public JsonSerializerOptions ToolCallJsonSerializerOptions + { + get => _toolCallJsonSerializerOptions; + set => _toolCallJsonSerializerOptions = Throw.IfNull(value); + } /// public ChatClientMetadata Metadata { get; } @@ -593,7 +599,7 @@ private sealed class OpenAIChatToolJson { try { - result = JsonSerializer.Serialize(resultContent.Result, JsonContext.GetTypeInfo(typeof(object), ToolCallJsonSerializerOptions)); + result = JsonSerializer.Serialize(resultContent.Result, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -622,7 +628,7 @@ private sealed class OpenAIChatToolJson callRequest.Name, new(JsonSerializer.SerializeToUtf8Bytes( callRequest.Arguments, - JsonContext.GetTypeInfo(typeof(IDictionary), ToolCallJsonSerializerOptions))))); + ToolCallJsonSerializerOptions.GetTypeInfo(typeof(IDictionary)))))); } } @@ -668,11 +674,13 @@ private static List GetContentParts(IList con private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); + argumentParser: static json => JsonSerializer.Deserialize(json, + (JsonTypeInfo>)AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))!); private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, JsonContext.Default.IDictionaryStringObject)!); + argumentParser: static json => JsonSerializer.Deserialize(json, + (JsonTypeInfo>)AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))!); /// Source-generated JSON type information. [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, @@ -680,48 +688,5 @@ private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true)] [JsonSerializable(typeof(OpenAIChatToolJson))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(JsonElement))] - private sealed partial class JsonContext : JsonSerializerContext - { - /// Gets the singleton used as the default in JSON serialization operations. - private static readonly JsonSerializerOptions _defaultToolJsonOptions = CreateDefaultToolJsonOptions(); - - /// Gets JSON type information for the specified type. - /// - /// This first tries to get the type information from , - /// falling back to if it can't. - /// - public static JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions? firstOptions) => - firstOptions?.TryGetTypeInfo(type, out JsonTypeInfo? info) is true ? - info : - _defaultToolJsonOptions.GetTypeInfo(type); - - /// Creates the default to use for serialization-related operations. - [UnconditionalSuppressMessage("AotAnalysis", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] - private static JsonSerializerOptions CreateDefaultToolJsonOptions() - { - // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, - // and we want to be flexible in terms of what can be put into the various collections in the object model. - // Otherwise, use the source-generated options to enable trimming and Native AOT. - - if (JsonSerializer.IsReflectionEnabledByDefault) - { - // Keep in sync with the JsonSourceGenerationOptions attribute on JsonContext above. - JsonSerializerOptions options = new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = new DefaultJsonTypeInfoResolver(), - Converters = { new JsonStringEnumConverter() }, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true, - }; - - options.MakeReadOnly(); - return options; - } - - return Default.Options; - } - } + private sealed partial class JsonContext : JsonSerializerContext; } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index f404f5e61ef..476ad973ddc 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Linq; using System.Net.Http; +using System.Text.Json; using System.Threading.Tasks; using Azure; using Azure.AI.Inference; @@ -29,6 +30,19 @@ public void Ctor_InvalidArgs_Throws() Assert.Throws("modelId", () => new AzureAIInferenceChatClient(client, " ")); } + [Fact] + public void ToolCallJsonSerializerOptions_HasExpectedValue() + { + using AzureAIInferenceChatClient client = new(new(new("http://somewhere"), new AzureKeyCredential("key")), "mode"); + + 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 AsChatClient_InvalidArgs_Throws() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs index 67b10e3f24b..3879e9e2ec3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs @@ -6,6 +6,7 @@ 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; @@ -26,6 +27,19 @@ public void Ctor_InvalidArgs_Throws() 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() { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 05d2f5a22ff..fb912235cfc 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -8,6 +8,7 @@ using System.ComponentModel; using System.Linq; using System.Net.Http; +using System.Text.Json; using System.Threading.Tasks; using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; @@ -34,6 +35,19 @@ public void Ctor_InvalidArgs_Throws() Assert.Throws("modelId", () => new OpenAIChatClient(openAIClient, " ")); } + [Fact] + public void ToolCallJsonSerializerOptions_HasExpectedValue() + { + using OpenAIChatClient client = new(new("key"), "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 AsChatClient_InvalidArgs_Throws() { From 430065ce564ffa389d0f2c28f05f36b0f601c395 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 14 Nov 2024 00:05:51 -0500 Subject: [PATCH 132/190] Rework cache key handling in caching client / generator (#5641) * Rework cache key handling in caching client / generator - Expose the default cache key helper so that customization doesn't require re-implementing the whole thing. - Make it easy to incorporate additional state into the cache key. - Avoid serializing all of the values for the key into a new byte[], at least on .NET 8+. There, we can serialize directly into a stream that targets an IncrementalHash. - Include Chat/EmbeddingGenerationOptions in the cache key by default. * Update test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs Co-authored-by: Shyam N --------- Co-authored-by: Shyam N --- .../Microsoft.Extensions.AI/CachingHelpers.cs | 133 +++++++++++++----- .../DistributedCachingChatClient.cs | 28 ++-- .../DistributedCachingEmbeddingGenerator.cs | 17 ++- .../DistributedCachingChatClientTest.cs | 21 ++- ...istributedCachingEmbeddingGeneratorTest.cs | 27 +++- .../TestJsonSerializerContext.cs | 2 + 6 files changed, 173 insertions(+), 55 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs b/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs index 13637dc5226..102fc86b138 100644 --- a/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs +++ b/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs @@ -2,9 +2,18 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics; +using System.IO; using System.Security.Cryptography; using System.Text.Json; -using Microsoft.Shared.Diagnostics; +#if NET +using System.Threading; +using System.Threading.Tasks; +#endif + +#pragma warning disable S109 // Magic numbers should not be used +#pragma warning disable SA1202 // Elements should be ordered by access +#pragma warning disable SA1502 // Element should not be on a single line namespace Microsoft.Extensions.AI; @@ -12,50 +21,110 @@ namespace Microsoft.Extensions.AI; internal static class CachingHelpers { /// Computes a default cache key for the specified parameters. - /// Specifies the type of the data being used to compute the key. - /// The data with which to compute the key. - /// The . - /// A string that will be used as a cache key. - public static string GetCacheKey(TValue value, JsonSerializerOptions serializerOptions) - => GetCacheKey(value, false, serializerOptions); - - /// Computes a default cache key for the specified parameters. - /// Specifies the type of the data being used to compute the key. - /// The data with which to compute the key. - /// Another data item that causes the key to vary. + /// The data with which to compute the key. /// The . /// A string that will be used as a cache key. - public static string GetCacheKey(TValue value, bool flag, JsonSerializerOptions serializerOptions) + public static string GetCacheKey(ReadOnlySpan values, JsonSerializerOptions serializerOptions) { - _ = Throw.IfNull(value); - _ = Throw.IfNull(serializerOptions); - serializerOptions.MakeReadOnly(); - - var jsonKeyBytes = JsonSerializer.SerializeToUtf8Bytes(value, serializerOptions.GetTypeInfo(typeof(TValue))); - - if (flag && jsonKeyBytes.Length > 0) - { - // Make an arbitrary change to the hash input based on the flag - // The alternative would be including the flag in "value" in the - // first place, but that's likely to require an extra allocation - // or the inclusion of another type in the JsonSerializerContext. - // This is a micro-optimization we can change at any time. - jsonKeyBytes[0] = (byte)(byte.MaxValue - jsonKeyBytes[0]); - } + Debug.Assert(serializerOptions is not null, "Expected serializer options to be non-null"); + Debug.Assert(serializerOptions!.IsReadOnly, "Expected serializer options to already be read-only."); // The complete JSON representation is excessively long for a cache key, duplicating much of the content // from the value. So we use a hash of it as the default key, and we rely on collision resistance for security purposes. // If a collision occurs, we'd serve the cached LLM response for a potentially unrelated prompt, leading to information // disclosure. Use of SHA256 is an implementation detail and can be easily swapped in the future if needed, albeit // invalidating any existing cache entries that may exist in whatever IDistributedCache was in use. -#if NET8_0_OR_GREATER + +#if NET + IncrementalHashStream? stream = IncrementalHashStream.ThreadStaticInstance ?? new(); + IncrementalHashStream.ThreadStaticInstance = null; + + foreach (object? value in values) + { + JsonSerializer.Serialize(stream, value, serializerOptions.GetTypeInfo(typeof(object))); + } + Span hashData = stackalloc byte[SHA256.HashSizeInBytes]; - SHA256.HashData(jsonKeyBytes, hashData); + stream.GetHashAndReset(hashData); + IncrementalHashStream.ThreadStaticInstance = stream; + return Convert.ToHexString(hashData); #else + MemoryStream stream = new(); + foreach (object? value in values) + { + JsonSerializer.Serialize(stream, value, serializerOptions.GetTypeInfo(typeof(object))); + } + using var sha256 = SHA256.Create(); - var hashData = sha256.ComputeHash(jsonKeyBytes); - return BitConverter.ToString(hashData).Replace("-", string.Empty); + stream.Position = 0; + var hashData = sha256.ComputeHash(stream.GetBuffer(), 0, (int)stream.Length); + + var chars = new char[hashData.Length * 2]; + int destPos = 0; + foreach (byte b in hashData) + { + int div = Math.DivRem(b, 16, out int rem); + chars[destPos++] = ToHexChar(div); + chars[destPos++] = ToHexChar(rem); + + static char ToHexChar(int i) => (char)(i < 10 ? i + '0' : i - 10 + 'A'); + } + + Debug.Assert(destPos == chars.Length, "Expected to have filled the entire array."); + + return new string(chars); #endif } + +#if NET + /// Provides a stream that writes to an . + private sealed class IncrementalHashStream : Stream + { + /// A per-thread instance of . + /// An instance stored must be in a reset state ready to be used by another consumer. + [ThreadStatic] + public static IncrementalHashStream? ThreadStaticInstance; + + /// Gets the current hash and resets. + public void GetHashAndReset(Span bytes) => _hash.GetHashAndReset(bytes); + + /// The used by this instance. + private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + + protected override void Dispose(bool disposing) + { + _hash.Dispose(); + base.Dispose(disposing); + } + + public override void WriteByte(byte value) => Write(new ReadOnlySpan(in value)); + public override void Write(byte[] buffer, int offset, int count) => _hash.AppendData(buffer, offset, count); + public override void Write(ReadOnlySpan buffer) => _hash.AppendData(buffer); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + Write(buffer, offset, count); + return Task.CompletedTask; + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + Write(buffer.Span); + return ValueTask.CompletedTask; + } + + public override void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public override bool CanWrite => true; + public override bool CanRead => false; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } +#endif } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs index 6ea79f9f738..678e9bd6523 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.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; using System.Collections.Generic; using System.Text.Json; using System.Threading; @@ -19,8 +20,17 @@ namespace Microsoft.Extensions.AI; /// public class DistributedCachingChatClient : CachingChatClient { + /// A boxed value. + private static readonly object _boxedTrue = true; + + /// A boxed value. + private static readonly object _boxedFalse = false; + + /// The instance that will be used as the backing store for the cache. private readonly IDistributedCache _storage; - private JsonSerializerOptions _jsonSerializerOptions; + + /// The to use when serializing cache data. + private JsonSerializerOptions _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; /// Initializes a new instance of the class. /// The underlying . @@ -29,7 +39,6 @@ public DistributedCachingChatClient(IChatClient innerClient, IDistributedCache s : base(innerClient) { _storage = Throw.IfNull(storage); - _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; } /// Gets or sets JSON serialization options to use when serializing cache data. @@ -90,13 +99,16 @@ protected override async Task WriteCacheStreamingAsync(string key, IReadOnlyList } /// - protected override string GetCacheKey(bool streaming, IList chatMessages, ChatOptions? options) + protected override string GetCacheKey(bool streaming, IList chatMessages, ChatOptions? options) => + GetCacheKey([streaming ? _boxedTrue : _boxedFalse, chatMessages, options]); + + /// Gets a cache key based on the supplied values. + /// The values to inform the key. + /// The computed key. + /// This provides the default implementation for . + protected string GetCacheKey(ReadOnlySpan values) { - // While it might be desirable to include ChatOptions in the cache key, it's not always possible, - // since ChatOptions can contain types that are not guaranteed to be serializable or have a stable - // hashcode across multiple calls. So the default cache key is simply the JSON representation of - // the chat contents. Developers may subclass and override this to provide custom rules. _jsonSerializerOptions.MakeReadOnly(); - return CachingHelpers.GetCacheKey(chatMessages, streaming, _jsonSerializerOptions); + return CachingHelpers.GetCacheKey(values, _jsonSerializerOptions); } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs index ecec409a1b3..6482ed8ed2b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.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; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using System.Threading; @@ -74,12 +75,16 @@ protected override async Task WriteCacheAsync(string key, TEmbedding value, Canc } /// - protected override string GetCacheKey(TInput value, EmbeddingGenerationOptions? options) + protected override string GetCacheKey(TInput value, EmbeddingGenerationOptions? options) => + GetCacheKey([value, options]); + + /// Gets a cache key based on the supplied values. + /// The values to inform the key. + /// The computed key. + /// This provides the default implementation for . + protected string GetCacheKey(ReadOnlySpan values) { - // While it might be desirable to include options in the cache key, it's not always possible, - // since options can contain types that are not guaranteed to be serializable or have a stable - // hashcode across multiple calls. So the default cache key is simply the JSON representation of - // the value. Developers may subclass and override this to provide custom rules. - return CachingHelpers.GetCacheKey(value, _jsonSerializerOptions); + _jsonSerializerOptions.MakeReadOnly(); + return CachingHelpers.GetCacheKey(values, _jsonSerializerOptions); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs index 67e23ec495c..772bb9cf7d6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs @@ -527,7 +527,7 @@ public async Task StreamingDoesNotCacheCanceledResultsAsync() } [Fact] - public async Task CacheKeyDoesNotVaryByChatOptionsAsync() + public async Task CacheKeyVariesByChatOptionsAsync() { // Arrange var innerCallCount = 0; @@ -546,20 +546,35 @@ public async Task CacheKeyDoesNotVaryByChatOptionsAsync() JsonSerializerOptions = TestJsonSerializerContext.Default.Options }; - // Act: Call with two different ChatOptions + // Act: Call with two different ChatOptions that have the same values var result1 = await outer.CompleteAsync([], new ChatOptions { AdditionalProperties = new() { { "someKey", "value 1" } } }); var result2 = await outer.CompleteAsync([], new ChatOptions { - AdditionalProperties = new() { { "someKey", "value 2" } } + AdditionalProperties = new() { { "someKey", "value 1" } } }); // Assert: Same result Assert.Equal(1, innerCallCount); Assert.Equal("value 1", result1.Message.Text); Assert.Equal("value 1", result2.Message.Text); + + // Act: Call with two different ChatOptions that have different values + var result3 = await outer.CompleteAsync([], new ChatOptions + { + AdditionalProperties = new() { { "someKey", "value 1" } } + }); + var result4 = await outer.CompleteAsync([], new ChatOptions + { + AdditionalProperties = new() { { "someKey", "value 2" } } + }); + + // Assert: Different results + Assert.Equal(2, innerCallCount); + Assert.Equal("value 1", result3.Message.Text); + Assert.Equal("value 2", result4.Message.Text); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs index a2818c7c3ed..f9356ef45c9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs @@ -221,7 +221,7 @@ public async Task DoesNotCacheCanceledResultsAsync() } [Fact] - public async Task CacheKeyDoesNotVaryByEmbeddingOptionsAsync() + public async Task CacheKeyVariesByEmbeddingOptionsAsync() { // Arrange var innerCallCount = 0; @@ -232,7 +232,7 @@ public async Task CacheKeyDoesNotVaryByEmbeddingOptionsAsync() { innerCallCount++; await Task.Yield(); - return [_expectedEmbedding]; + return [new(((string)options!.AdditionalProperties!["someKey"]!).Select(c => (float)c).ToArray())]; } }; using var outer = new DistributedCachingEmbeddingGenerator>(innerGenerator, _storage) @@ -240,20 +240,35 @@ public async Task CacheKeyDoesNotVaryByEmbeddingOptionsAsync() JsonSerializerOptions = TestJsonSerializerContext.Default.Options, }; - // Act: Call with two different options + // Act: Call with two different EmbeddingGenerationOptions that have the same values var result1 = await outer.GenerateEmbeddingAsync("abc", new EmbeddingGenerationOptions { AdditionalProperties = new() { ["someKey"] = "value 1" } }); var result2 = await outer.GenerateEmbeddingAsync("abc", new EmbeddingGenerationOptions { - AdditionalProperties = new() { ["someKey"] = "value 2" } + AdditionalProperties = new() { ["someKey"] = "value 1" } }); // Assert: Same result Assert.Equal(1, innerCallCount); - AssertEmbeddingsEqual(_expectedEmbedding, result1); - AssertEmbeddingsEqual(_expectedEmbedding, result2); + AssertEmbeddingsEqual(new("value 1".Select(c => (float)c).ToArray()), result1); + AssertEmbeddingsEqual(new("value 1".Select(c => (float)c).ToArray()), result2); + + // Act: Call with two different EmbeddingGenerationOptions that have different values + var result3 = await outer.GenerateEmbeddingAsync("abc", new EmbeddingGenerationOptions + { + AdditionalProperties = new() { ["someKey"] = "value 1" } + }); + var result4 = await outer.GenerateEmbeddingAsync("abc", new EmbeddingGenerationOptions + { + AdditionalProperties = new() { ["someKey"] = "value 2" } + }); + + // Assert: Different result + Assert.Equal(2, innerCallCount); + AssertEmbeddingsEqual(new("value 1".Select(c => (float)c).ToArray()), result3); + AssertEmbeddingsEqual(new("value 2".Select(c => (float)c).ToArray()), result4); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/TestJsonSerializerContext.cs index e376da86dad..b077542c17c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/TestJsonSerializerContext.cs @@ -25,4 +25,6 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(DayOfWeek[]))] [JsonSerializable(typeof(Guid))] +[JsonSerializable(typeof(ChatOptions))] +[JsonSerializable(typeof(EmbeddingGenerationOptions))] internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; From d39bf3dfecf2df69676e0ee6678dd606b234c2c8 Mon Sep 17 00:00:00 2001 From: Genevieve Warren <24882762+gewarren@users.noreply.github.com> Date: Wed, 13 Nov 2024 21:06:04 -0800 Subject: [PATCH 133/190] docs updates (#5643) --- .../AdditionalPropertiesDictionary.cs | 4 ++-- .../ChatCompletion/ChatOptions.cs | 2 +- .../ConfigureOptionsChatClientBuilderExtensions.cs | 4 ++-- .../Embeddings/ConfigureOptionsEmbeddingGenerator.cs | 6 +++--- .../ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs index 4a681d4679a..8b8d69896bf 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs @@ -153,8 +153,8 @@ public bool TryAdd(string key, object? value) /// in the dictionary and converted to the requested type; otherwise, . /// /// - /// If a non- is found for the key in the dictionary, but the value is not of the requested type but is - /// an object, the method will attempt to convert the object to the requested type. + /// If a non- value is found for the key in the dictionary, but the value is not of the requested type and is + /// an object, the method attempts to convert the object to the requested type. /// public bool TryGetValue(string key, [NotNullWhen(true)] out T? value) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index 63ccb69031a..f3d3621aa69 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -27,7 +27,7 @@ public class ChatOptions /// Gets or sets the presence penalty for generating chat responses. public float? PresencePenalty { get; set; } - /// Gets or sets a seed value used by a service to control the reproducability of results. + /// Gets or sets a seed value used by a service to control the reproducibility of results. public long? Seed { get; set; } /// diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs index 5c160794a9f..ea990d09a85 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ConfigureOptionsChatClientBuilderExtensions.cs @@ -20,8 +20,8 @@ public static class ConfigureOptionsChatClientBuilderExtensions /// It is passed a clone of the caller-supplied instance (or a newly constructed instance if the caller-supplied instance is ). /// /// - /// This can be used to set default options. The delegate is passed either a new instance of - /// if the caller didn't supply a instance, or a clone (via + /// This method can be used to set default options. The delegate is passed either a new instance of + /// if the caller didn't supply a instance, or a clone (via ) /// of the caller-supplied instance if one was supplied. /// /// The . diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs index c956a0bfe9b..8332064f22a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGenerator.cs @@ -9,9 +9,9 @@ namespace Microsoft.Extensions.AI; -/// A delegating embedding generator that configures a instance used by the remainder of the pipeline. -/// Specifies the type of the input passed to the generator. -/// Specifies the type of the embedding instance produced by the generator. +/// Represents a delegating embedding generator that configures a instance used by the remainder of the pipeline. +/// The type of the input passed to the generator. +/// The type of the embedding instance produced by the generator. public sealed class ConfigureOptionsEmbeddingGenerator : DelegatingEmbeddingGenerator where TEmbedding : Embedding { diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs index 4bf0a7b9e6e..51f1804c2df 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs @@ -14,8 +14,8 @@ public static class ConfigureOptionsEmbeddingGeneratorBuilderExtensions /// /// Adds a callback that configures a to be passed to the next client in the pipeline. /// - /// Specifies the type of the input passed to the generator. - /// Specifies the type of the embedding instance produced by the generator. + /// The type of the input passed to the generator. + /// The type of the embedding instance produced by the generator. /// The . /// /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied From 56e720c6887c344a428ed989e9e352345ee56210 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 14 Nov 2024 05:36:08 -0800 Subject: [PATCH 134/190] Change ChatClientBuilder to register singletons and support lambda-less chaining (#5642) * Change ChatClientBuilder to register singletons and support lambda-less chaining * Add generic keyed version * Improve XML doc * Update README files * Remove generic DI registration methods --- .../README.md | 25 +++-- .../README.md | 25 +++-- .../Microsoft.Extensions.AI.Ollama/README.md | 25 +++-- .../Microsoft.Extensions.AI.OpenAI/README.md | 25 +++-- .../ChatCompletion/ChatClientBuilder.cs | 33 ++++--- ...lientBuilderServiceCollectionExtensions.cs | 70 ++++++++----- .../AzureAIInferenceChatClientTests.cs | 4 +- .../ChatClientIntegrationTests.cs | 16 +-- .../ReducingChatClientTests.cs | 4 +- .../OllamaChatClientIntegrationTests.cs | 8 +- .../OllamaChatClientTests.cs | 4 +- .../OpenAIChatClientTests.cs | 8 +- .../ChatCompletion/ChatClientBuilderTest.cs | 31 +++--- .../ConfigureOptionsChatClientTests.cs | 7 +- .../DependencyInjectionPatterns.cs | 99 +++++++++++-------- .../DistributedCachingChatClientTest.cs | 4 +- .../FunctionInvokingChatClientTests.cs | 4 +- .../ChatCompletion/LoggingChatClientTests.cs | 8 +- .../OpenTelemetryChatClientTests.cs | 4 +- .../ScopedChatClientExtensions.cs | 11 --- .../SingletonChatClientExtensions.cs | 11 +++ 21 files changed, 239 insertions(+), 187 deletions(-) delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ScopedChatClientExtensions.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/SingletonChatClientExtensions.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md index 7e8b369d80b..f02a0eff4a6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md @@ -150,9 +150,9 @@ using Microsoft.Extensions.AI; [Description("Gets the current weather")] string GetCurrentWeather() => Random.Shared.NextDouble() > 0.5 ? "It's sunny" : "It's raining"; -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1")) .UseFunctionInvocation() - .Use(new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1")); + .Build(); var response = client.CompleteStreamingAsync( "Should I wear a rain coat?", @@ -174,9 +174,9 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model")) .UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) - .Use(new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model")); + .Build(); string[] prompts = ["What is AI?", "What is .NET?", "What is AI?"]; @@ -205,9 +205,9 @@ var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddConsoleExporter() .Build(); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model")) .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) - .Use(new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model")); + .Build(); Console.WriteLine((await client.CompleteAsync("What is AI?")).Message); ``` @@ -220,9 +220,9 @@ Options may also be baked into an `IChatClient` via the `ConfigureOptions` exten ```csharp using Microsoft.Extensions.AI; -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(new OllamaChatClient(new Uri("http://localhost:11434"))) .ConfigureOptions(options => options.ModelId ??= "phi3") - .Use(new OllamaChatClient(new Uri("http://localhost:11434"))); + .Build(); Console.WriteLine(await client.CompleteAsync("What is AI?")); // will request "phi3" Console.WriteLine(await client.CompleteAsync("What is AI?", new() { ModelId = "llama3.1" })); // will request "llama3.1" @@ -248,11 +248,11 @@ var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() // Explore changing the order of the intermediate "Use" calls to see that impact // that has on what gets cached, traced, etc. -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1")) .UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) .UseFunctionInvocation() .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) - .Use(new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1")); + .Build(); ChatOptions options = new() { @@ -341,9 +341,8 @@ using Microsoft.Extensions.Hosting; // App Setup var builder = Host.CreateApplicationBuilder(); builder.Services.AddDistributedMemoryCache(); -builder.Services.AddChatClient(b => b - .UseDistributedCache() - .Use(new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model"))); +builder.Services.AddChatClient(new SampleChatClient(new Uri("http://coolsite.ai"), "my-custom-model")) + .UseDistributedCache(); var host = builder.Build(); // Elsewhere in the app diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md index f34e89a08fb..65396b80307 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md @@ -85,9 +85,9 @@ IChatClient azureClient = new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) .AsChatClient("gpt-4o-mini"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(azureClient) .UseFunctionInvocation() - .Use(azureClient); + .Build(); ChatOptions chatOptions = new() { @@ -120,9 +120,9 @@ IChatClient azureClient = new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) .AsChatClient("gpt-4o-mini"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(azureClient) .UseDistributedCache(cache) - .Use(azureClient); + .Build(); for (int i = 0; i < 3; i++) { @@ -156,9 +156,9 @@ IChatClient azureClient = new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) .AsChatClient("gpt-4o-mini"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(azureClient) .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) - .Use(azureClient); + .Build(); Console.WriteLine(await client.CompleteAsync("What is AI?")); ``` @@ -196,11 +196,11 @@ IChatClient azureClient = new AzureKeyCredential(Environment.GetEnvironmentVariable("GH_TOKEN")!)) .AsChatClient("gpt-4o-mini"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(azureClient) .UseDistributedCache(cache) .UseFunctionInvocation() .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) - .Use(azureClient); + .Build(); for (int i = 0; i < 3; i++) { @@ -236,10 +236,9 @@ builder.Services.AddSingleton( builder.Services.AddDistributedMemoryCache(); builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); -builder.Services.AddChatClient(b => b +builder.Services.AddChatClient(services => services.GetRequiredService().AsChatClient("gpt-4o-mini")) .UseDistributedCache() - .UseLogging() - .Use(b.Services.GetRequiredService().AsChatClient("gpt-4o-mini"))); + .UseLogging(); var app = builder.Build(); @@ -261,8 +260,8 @@ builder.Services.AddSingleton(new ChatCompletionsClient( new("https://models.inference.ai.azure.com"), new AzureKeyCredential(builder.Configuration["GH_TOKEN"]!))); -builder.Services.AddChatClient(b => - b.Use(b.Services.GetRequiredService().AsChatClient("gpt-4o-mini"))); +builder.Services.AddChatClient(services => + services.GetRequiredService().AsChatClient("gpt-4o-mini")); var app = builder.Build(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md index 3d2eddcafc1..1eae652e7c8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md @@ -70,9 +70,9 @@ using Microsoft.Extensions.AI; IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(ollamaClient) .UseFunctionInvocation() - .Use(ollamaClient); + .Build(); ChatOptions chatOptions = new() { @@ -97,9 +97,9 @@ IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDi IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(ollamaClient) .UseDistributedCache(cache) - .Use(ollamaClient); + .Build(); for (int i = 0; i < 3; i++) { @@ -128,9 +128,9 @@ var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(ollamaClient) .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) - .Use(ollamaClient); + .Build(); Console.WriteLine(await client.CompleteAsync("What is AI?")); ``` @@ -163,11 +163,11 @@ var chatOptions = new ChatOptions IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(ollamaClient) .UseDistributedCache(cache) .UseFunctionInvocation() .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) - .Use(ollamaClient); + .Build(); for (int i = 0; i < 3; i++) { @@ -235,10 +235,9 @@ var builder = Host.CreateApplicationBuilder(); builder.Services.AddDistributedMemoryCache(); builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); -builder.Services.AddChatClient(b => b +builder.Services.AddChatClient(new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1")) .UseDistributedCache() - .UseLogging() - .Use(new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"))); + .UseLogging(); var app = builder.Build(); @@ -254,8 +253,8 @@ using Microsoft.Extensions.AI; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddChatClient(c => - c.Use(new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"))); +builder.Services.AddChatClient( + new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1")); builder.Services.AddEmbeddingGenerator>(g => g.Use(new OllamaEmbeddingGenerator(endpoint, "all-minilm"))); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md index 696cc0c01bf..fa0e2956e86 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md @@ -77,9 +77,9 @@ IChatClient openaiClient = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) .AsChatClient("gpt-4o-mini"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(openaiClient) .UseFunctionInvocation() - .Use(openaiClient); + .Build(); ChatOptions chatOptions = new() { @@ -110,9 +110,9 @@ IChatClient openaiClient = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) .AsChatClient("gpt-4o-mini"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(openaiClient) .UseDistributedCache(cache) - .Use(openaiClient); + .Build(); for (int i = 0; i < 3; i++) { @@ -144,9 +144,9 @@ IChatClient openaiClient = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) .AsChatClient("gpt-4o-mini"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(openaiClient) .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) - .Use(openaiClient); + .Build(); Console.WriteLine(await client.CompleteAsync("What is AI?")); ``` @@ -182,11 +182,11 @@ IChatClient openaiClient = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) .AsChatClient("gpt-4o-mini"); -IChatClient client = new ChatClientBuilder() +IChatClient client = new ChatClientBuilder(openaiClient) .UseDistributedCache(cache) .UseFunctionInvocation() .UseOpenTelemetry(sourceName, c => c.EnableSensitiveData = true) - .Use(openaiClient); + .Build(); for (int i = 0; i < 3; i++) { @@ -260,10 +260,9 @@ builder.Services.AddSingleton(new OpenAIClient(Environment.GetEnvironmentVariabl builder.Services.AddDistributedMemoryCache(); builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); -builder.Services.AddChatClient(b => b +builder.Services.AddChatClient(services => services.GetRequiredService().AsChatClient("gpt-4o-mini")) .UseDistributedCache() - .UseLogging() - .Use(b.Services.GetRequiredService().AsChatClient("gpt-4o-mini"))); + .UseLogging(); var app = builder.Build(); @@ -282,8 +281,8 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(new OpenAIClient(builder.Configuration["OPENAI_API_KEY"])); -builder.Services.AddChatClient(b => - b.Use(b.Services.GetRequiredService().AsChatClient("gpt-4o-mini"))); +builder.Services.AddChatClient(services => + services.GetRequiredService().AsChatClient("gpt-4o-mini")); builder.Services.AddEmbeddingGenerator>(g => g.Use(g.Services.GetRequiredService().AsEmbeddingGenerator("text-embedding-3-small"))); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs index d7934ba7809..abbf4776d51 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs @@ -10,32 +10,43 @@ namespace Microsoft.Extensions.AI; /// A builder for creating pipelines of . public sealed class ChatClientBuilder { + private Func _innerClientFactory; + /// The registered client factory instances. private List>? _clientFactories; /// Initializes a new instance of the class. - /// The service provider to use for dependency injection. - public ChatClientBuilder(IServiceProvider? services = null) + /// The inner that represents the underlying backend. + public ChatClientBuilder(IChatClient innerClient) { - Services = services ?? EmptyServiceProvider.Instance; + _ = Throw.IfNull(innerClient); + _innerClientFactory = _ => innerClient; } - /// Gets the associated with the builder instance. - public IServiceProvider Services { get; } + /// Initializes a new instance of the class. + /// A callback that produces the inner that represents the underlying backend. + public ChatClientBuilder(Func innerClientFactory) + { + _innerClientFactory = Throw.IfNull(innerClientFactory); + } - /// Completes the pipeline by adding a final that represents the underlying backend. This is typically a client for an LLM service. - /// The inner client to use. - /// An instance of that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. - public IChatClient Use(IChatClient innerClient) + /// Returns an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. + /// + /// The that should provide services to the instances. + /// If null, an empty will be used. + /// + /// An instance of that represents the entire pipeline. + public IChatClient Build(IServiceProvider? services = null) { - var chatClient = Throw.IfNull(innerClient); + services ??= EmptyServiceProvider.Instance; + var chatClient = _innerClientFactory(services); // To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost. if (_clientFactories is not null) { for (var i = _clientFactories.Count - 1; i >= 0; i--) { - chatClient = _clientFactories[i](Services, chatClient) ?? + chatClient = _clientFactories[i](services, chatClient) ?? throw new InvalidOperationException( $"The {nameof(ChatClientBuilder)} entry at index {i} returned null. " + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IChatClient)} instances."); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs index 9d419f434af..a057a507f24 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs @@ -10,38 +10,62 @@ namespace Microsoft.Extensions.DependencyInjection; /// Provides extension methods for registering with a . public static class ChatClientBuilderServiceCollectionExtensions { - /// Adds a chat client to the . - /// The to which the client should be added. - /// The factory to use to construct the instance. - /// The collection. - /// The client is registered as a scoped service. - public static IServiceCollection AddChatClient( - this IServiceCollection services, - Func clientFactory) + /// Registers a singleton in the . + /// The to which the client should be added. + /// The inner that represents the underlying backend. + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a singleton service. + public static ChatClientBuilder AddChatClient( + this IServiceCollection serviceCollection, + IChatClient innerClient) + => AddChatClient(serviceCollection, _ => innerClient); + + /// Registers a singleton in the . + /// The to which the client should be added. + /// A callback that produces the inner that represents the underlying backend. + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a singleton service. + public static ChatClientBuilder AddChatClient( + this IServiceCollection serviceCollection, + Func innerClientFactory) { - _ = Throw.IfNull(services); - _ = Throw.IfNull(clientFactory); + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerClientFactory); - return services.AddScoped(services => - clientFactory(new ChatClientBuilder(services))); + var builder = new ChatClientBuilder(innerClientFactory); + _ = serviceCollection.AddSingleton(builder.Build); + return builder; } - /// Adds a chat client to the . - /// The to which the client should be added. + /// Registers a singleton in the . + /// The to which the client should be added. + /// The key with which to associate the client. + /// The inner that represents the underlying backend. + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a scoped service. + public static ChatClientBuilder AddKeyedChatClient( + this IServiceCollection serviceCollection, + object serviceKey, + IChatClient innerClient) + => AddKeyedChatClient(serviceCollection, serviceKey, _ => innerClient); + + /// Registers a singleton in the . + /// The to which the client should be added. /// The key with which to associate the client. - /// The factory to use to construct the instance. - /// The collection. + /// A callback that produces the inner that represents the underlying backend. + /// A that can be used to build a pipeline around the inner client. /// The client is registered as a scoped service. - public static IServiceCollection AddKeyedChatClient( - this IServiceCollection services, + public static ChatClientBuilder AddKeyedChatClient( + this IServiceCollection serviceCollection, object serviceKey, - Func clientFactory) + Func innerClientFactory) { - _ = Throw.IfNull(services); + _ = Throw.IfNull(serviceCollection); _ = Throw.IfNull(serviceKey); - _ = Throw.IfNull(clientFactory); + _ = Throw.IfNull(innerClientFactory); - return services.AddKeyedScoped(serviceKey, (services, _) => - clientFactory(new ChatClientBuilder(services))); + var builder = new ChatClientBuilder(innerClientFactory); + _ = serviceCollection.AddKeyedSingleton(serviceKey, (services, _) => builder.Build(services)); + return builder; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 476ad973ddc..c0f79efdd62 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -77,11 +77,11 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.Same(client, chatClient.GetService()); - using IChatClient pipeline = new ChatClientBuilder() + using IChatClient pipeline = new ChatClientBuilder(chatClient) .UseFunctionInvocation() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Use(chatClient); + .Build(); Assert.NotNull(pipeline.GetService()); Assert.NotNull(pipeline.GetService()); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index ce376e3927d..871769df33c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -377,12 +377,12 @@ public virtual async Task Caching_BeforeFunctionInvocation_AvoidsExtraCalls() }, "GetTemperature"); // First call executes the function and calls the LLM - using var chatClient = new ChatClientBuilder() + using var chatClient = new ChatClientBuilder(CreateChatClient()!) .ConfigureOptions(options => options.Tools = [getTemperature]) .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .UseFunctionInvocation() .UseCallCounting() - .Use(CreateChatClient()!); + .Build(); var llmCallCount = chatClient.GetService(); var message = new ChatMessage(ChatRole.User, "What is the temperature?"); @@ -415,12 +415,12 @@ public virtual async Task Caching_AfterFunctionInvocation_FunctionOutputUnchange }, "GetTemperature"); // First call executes the function and calls the LLM - using var chatClient = new ChatClientBuilder() + using var chatClient = new ChatClientBuilder(CreateChatClient()!) .ConfigureOptions(options => options.Tools = [getTemperature]) .UseFunctionInvocation() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .UseCallCounting() - .Use(CreateChatClient()!); + .Build(); var llmCallCount = chatClient.GetService(); var message = new ChatMessage(ChatRole.User, "What is the temperature?"); @@ -454,12 +454,12 @@ public virtual async Task Caching_AfterFunctionInvocation_FunctionOutputChangedA }, "GetTemperature"); // First call executes the function and calls the LLM - using var chatClient = new ChatClientBuilder() + using var chatClient = new ChatClientBuilder(CreateChatClient()!) .ConfigureOptions(options => options.Tools = [getTemperature]) .UseFunctionInvocation() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .UseCallCounting() - .Use(CreateChatClient()!); + .Build(); var llmCallCount = chatClient.GetService(); var message = new ChatMessage(ChatRole.User, "What is the temperature?"); @@ -573,9 +573,9 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() .AddInMemoryExporter(activities) .Build(); - var chatClient = new ChatClientBuilder() + var chatClient = new ChatClientBuilder(CreateChatClient()!) .UseOpenTelemetry(sourceName: sourceName) - .Use(CreateChatClient()!); + .Build(); var response = await chatClient.CompleteAsync([new(ChatRole.User, "What's the biggest animal?")]); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs index 684211ab60b..7e3783976dc 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs @@ -37,9 +37,9 @@ public async Task Reduction_LimitsMessagesBasedOnTokenLimit() } }; - using var client = new ChatClientBuilder() + using var client = new ChatClientBuilder(innerClient) .UseChatReducer(new TokenCountingChatReducer(_gpt4oTokenizer, 40)) - .Use(innerClient); + .Build(); List messages = [ diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs index 4c71690baaf..23d910f5e33 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs @@ -37,11 +37,11 @@ public async Task PromptBasedFunctionCalling_NoArgs() { SkipIfNotEnabled(); - using var chatClient = new ChatClientBuilder() + using var chatClient = new ChatClientBuilder(CreateChatClient()!) .UseFunctionInvocation() .UsePromptBasedFunctionCalling() .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) - .Use(CreateChatClient()!); + .Build(); var secretNumber = 42; var response = await chatClient.CompleteAsync("What is the current secret number? Answer with digits only.", new ChatOptions @@ -61,11 +61,11 @@ public async Task PromptBasedFunctionCalling_WithArgs() { SkipIfNotEnabled(); - using var chatClient = new ChatClientBuilder() + using var chatClient = new ChatClientBuilder(CreateChatClient()!) .UseFunctionInvocation() .UsePromptBasedFunctionCalling() .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) - .Use(CreateChatClient()!); + .Build(); var stockPriceTool = AIFunctionFactory.Create([Description("Returns the stock price for a given ticker symbol")] ( [Description("The ticker symbol")] string symbol, diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs index 3879e9e2ec3..4e01987a158 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs @@ -48,11 +48,11 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.Same(client, client.GetService()); Assert.Same(client, client.GetService()); - using IChatClient pipeline = new ChatClientBuilder() + using IChatClient pipeline = new ChatClientBuilder(client) .UseFunctionInvocation() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Use(client); + .Build(); Assert.NotNull(pipeline.GetService()); Assert.NotNull(pipeline.GetService()); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index fb912235cfc..41c118dc3cb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -95,11 +95,11 @@ public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() Assert.NotNull(chatClient.GetService()); - using IChatClient pipeline = new ChatClientBuilder() + using IChatClient pipeline = new ChatClientBuilder(chatClient) .UseFunctionInvocation() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Use(chatClient); + .Build(); Assert.NotNull(pipeline.GetService()); Assert.NotNull(pipeline.GetService()); @@ -119,11 +119,11 @@ public void GetService_ChatClient_SuccessfullyReturnsUnderlyingClient() Assert.Same(chatClient, chatClient.GetService()); Assert.Same(openAIClient, chatClient.GetService()); - using IChatClient pipeline = new ChatClientBuilder() + using IChatClient pipeline = new ChatClientBuilder(chatClient) .UseFunctionInvocation() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Use(chatClient); + .Build(); Assert.NotNull(pipeline.GetService()); Assert.NotNull(pipeline.GetService()); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs index ba1c85d700a..8630cfe1702 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs @@ -13,17 +13,23 @@ public class ChatClientBuilderTest public void PassesServiceProviderToFactories() { var expectedServiceProvider = new ServiceCollection().BuildServiceProvider(); - using TestChatClient expectedResult = new(); - var builder = new ChatClientBuilder(expectedServiceProvider); + using TestChatClient expectedInnerClient = new(); + using TestChatClient expectedOuterClient = new(); + + var builder = new ChatClientBuilder(services => + { + Assert.Same(expectedServiceProvider, services); + return expectedInnerClient; + }); builder.Use((serviceProvider, innerClient) => { Assert.Same(expectedServiceProvider, serviceProvider); - return expectedResult; + Assert.Same(expectedInnerClient, innerClient); + return expectedOuterClient; }); - using TestChatClient innerClient = new(); - Assert.Equal(expectedResult, builder.Use(innerClient: innerClient)); + Assert.Same(expectedOuterClient, builder.Build(expectedServiceProvider)); } [Fact] @@ -31,14 +37,14 @@ public void BuildsPipelineInOrderAdded() { // Arrange using TestChatClient expectedInnerClient = new(); - var builder = new ChatClientBuilder(); + var builder = new ChatClientBuilder(expectedInnerClient); builder.Use(next => new InnerClientCapturingChatClient("First", next)); builder.Use(next => new InnerClientCapturingChatClient("Second", next)); builder.Use(next => new InnerClientCapturingChatClient("Third", next)); // Act - var first = (InnerClientCapturingChatClient)builder.Use(expectedInnerClient); + var first = (InnerClientCapturingChatClient)builder.Build(); // Assert Assert.Equal("First", first.Name); @@ -52,23 +58,22 @@ public void BuildsPipelineInOrderAdded() [Fact] public void DoesNotAcceptNullInnerService() { - Assert.Throws(() => new ChatClientBuilder().Use((IChatClient)null!)); + Assert.Throws(() => new ChatClientBuilder((IChatClient)null!)); } [Fact] public void DoesNotAcceptNullFactories() { - ChatClientBuilder builder = new(); - Assert.Throws(() => builder.Use((Func)null!)); - Assert.Throws(() => builder.Use((Func)null!)); + Assert.Throws(() => new ChatClientBuilder((Func)null!)); } [Fact] public void DoesNotAllowFactoriesToReturnNull() { - ChatClientBuilder builder = new(); + using var innerClient = new TestChatClient(); + ChatClientBuilder builder = new(innerClient); builder.Use(_ => null!); - var ex = Assert.Throws(() => builder.Use(new TestChatClient())); + var ex = Assert.Throws(() => builder.Build()); Assert.Contains("entry at index 0", ex.Message); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs index 6b1e6587f1f..68a898dc743 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs @@ -22,7 +22,8 @@ public void ConfigureOptionsChatClient_InvalidArgs_Throws() [Fact] public void ConfigureOptions_InvalidArgs_Throws() { - var builder = new ChatClientBuilder(); + using var innerClient = new TestChatClient(); + var builder = new ChatClientBuilder(innerClient); Assert.Throws("configure", () => builder.ConfigureOptions(null!)); } @@ -54,7 +55,7 @@ public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullP }, }; - using var client = new ChatClientBuilder() + using var client = new ChatClientBuilder(innerClient) .ConfigureOptions(options => { Assert.NotSame(providedOptions, options); @@ -69,7 +70,7 @@ public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullP returnedOptions = options; }) - .Use(innerClient); + .Build(); var completion = await client.CompleteAsync(Array.Empty(), providedOptions, cts.Token); Assert.Same(expectedCompletion, completion); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs index 9bbfbea98c3..54c5011b103 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs @@ -12,12 +12,11 @@ public class DependencyInjectionPatterns private IServiceCollection ServiceCollection { get; } = new ServiceCollection(); [Fact] - public void CanRegisterScopedUsingGenericType() + public void CanRegisterSingletonUsingFactory() { // Arrange/Act - ServiceCollection.AddChatClient(builder => builder - .UseScopedMiddleware() - .Use(new TestChatClient())); + ServiceCollection.AddChatClient(services => new TestChatClient { Services = services }) + .UseSingletonMiddleware(); // Assert var services = ServiceCollection.BuildServiceProvider(); @@ -28,27 +27,20 @@ public void CanRegisterScopedUsingGenericType() var instance1Copy = scope1.ServiceProvider.GetRequiredService(); var instance2 = scope2.ServiceProvider.GetRequiredService(); - // Each scope gets a distinct outer *AND* inner client - var outer1 = Assert.IsType(instance1); - var outer2 = Assert.IsType(instance2); - var inner1 = Assert.IsType(((ScopedChatClient)instance1).InnerClient); - var inner2 = Assert.IsType(((ScopedChatClient)instance2).InnerClient); - - Assert.NotSame(outer1.Services, outer2.Services); - Assert.NotSame(instance1, instance2); - Assert.NotSame(inner1, inner2); - Assert.Same(instance1, instance1Copy); // From the same scope + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerClient); } [Fact] - public void CanRegisterScopedUsingFactory() + public void CanRegisterSingletonUsingSharedInstance() { // Arrange/Act - ServiceCollection.AddChatClient(builder => - { - builder.UseScopedMiddleware(); - return builder.Use(new TestChatClient { Services = builder.Services }); - }); + using var singleton = new TestChatClient(); + ServiceCollection.AddChatClient(singleton) + .UseSingletonMiddleware(); // Assert var services = ServiceCollection.BuildServiceProvider(); @@ -56,45 +48,68 @@ public void CanRegisterScopedUsingFactory() using var scope2 = services.CreateScope(); var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance1Copy = scope1.ServiceProvider.GetRequiredService(); var instance2 = scope2.ServiceProvider.GetRequiredService(); - // Each scope gets a distinct outer *AND* inner client - var outer1 = Assert.IsType(instance1); - var outer2 = Assert.IsType(instance2); - var inner1 = Assert.IsType(((ScopedChatClient)instance1).InnerClient); - var inner2 = Assert.IsType(((ScopedChatClient)instance2).InnerClient); + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerClient); + } + + [Fact] + public void CanRegisterKeyedSingletonUsingFactory() + { + // Arrange/Act + ServiceCollection.AddKeyedChatClient("mykey", services => new TestChatClient { Services = services }) + .UseSingletonMiddleware(); - Assert.Same(outer1.Services, inner1.Services); - Assert.Same(outer2.Services, inner2.Services); - Assert.NotSame(outer1.Services, outer2.Services); + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + Assert.Null(services.GetService()); + + var instance1 = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance1Copy = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance2 = scope2.ServiceProvider.GetRequiredKeyedService("mykey"); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerClient); } [Fact] - public void CanRegisterScopedUsingSharedInstance() + public void CanRegisterKeyedSingletonUsingSharedInstance() { // Arrange/Act using var singleton = new TestChatClient(); - ServiceCollection.AddChatClient(builder => - { - builder.UseScopedMiddleware(); - return builder.Use(singleton); - }); + ServiceCollection.AddKeyedChatClient("mykey", singleton) + .UseSingletonMiddleware(); // Assert var services = ServiceCollection.BuildServiceProvider(); using var scope1 = services.CreateScope(); using var scope2 = services.CreateScope(); - var instance1 = scope1.ServiceProvider.GetRequiredService(); - var instance2 = scope2.ServiceProvider.GetRequiredService(); - // Each scope gets a distinct outer instance, but the same inner client - Assert.IsType(instance1); - Assert.IsType(instance2); - Assert.Same(singleton, ((ScopedChatClient)instance1).InnerClient); - Assert.Same(singleton, ((ScopedChatClient)instance2).InnerClient); + Assert.Null(services.GetService()); + + var instance1 = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance1Copy = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance2 = scope2.ServiceProvider.GetRequiredKeyedService("mykey"); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerClient); } - public class ScopedChatClient(IServiceProvider services, IChatClient inner) : DelegatingChatClient(inner) + public class SingletonMiddleware(IServiceProvider services, IChatClient inner) : DelegatingChatClient(inner) { public new IChatClient InnerClient => base.InnerClient; public IServiceProvider Services => services; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs index 772bb9cf7d6..dcc6068b3ce 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs @@ -681,12 +681,12 @@ public async Task CanResolveIDistributedCacheFromDI() new(ChatRole.Assistant, [new TextContent("Hey")])])); } }; - using var outer = new ChatClientBuilder(services) + using var outer = new ChatClientBuilder(testClient) .UseDistributedCache(configure: options => { options.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; }) - .Use(testClient); + .Build(services); // Act: Make a request that should populate the cache Assert.Empty(_storage.Keys); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 542851baa69..1e4558901ca 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -295,7 +295,7 @@ public async Task RejectsMultipleChoicesAsync() } }; - IChatClient service = new ChatClientBuilder().UseFunctionInvocation().Use(innerClient); + IChatClient service = new ChatClientBuilder(innerClient).UseFunctionInvocation().Build(); List chat = [new ChatMessage(ChatRole.User, "hello")]; var ex = await Assert.ThrowsAsync( @@ -415,7 +415,7 @@ private static async Task> InvokeAndAssertAsync( } }; - IChatClient service = configurePipeline(new ChatClientBuilder()).Use(innerClient); + IChatClient service = configurePipeline(new ChatClientBuilder(innerClient)).Build(); var result = await service.CompleteAsync(chat, options, cts.Token); chat.Add(result.Message); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs index feb91ac925e..38bc4e8f67d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs @@ -40,9 +40,9 @@ public async Task CompleteAsync_LogsStartAndCompletion(LogLevel level) }, }; - using IChatClient client = new ChatClientBuilder(services) + using IChatClient client = new ChatClientBuilder(innerClient) .UseLogging() - .Use(innerClient); + .Build(services); await client.CompleteAsync( [new(ChatRole.User, "What's the biggest animal?")], @@ -86,9 +86,9 @@ static async IAsyncEnumerable GetUpdatesAsync() yield return new StreamingChatCompletionUpdate { Role = ChatRole.Assistant, Text = "whale" }; } - using IChatClient client = new ChatClientBuilder() + using IChatClient client = new ChatClientBuilder(innerClient) .UseLogging(logger) - .Use(innerClient); + .Build(); await foreach (var update in client.CompleteStreamingAsync( [new(ChatRole.User, "What's the biggest animal?")], diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs index 2ad428fad76..2080e2f02b2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs @@ -86,13 +86,13 @@ async static IAsyncEnumerable CallbackAsync( }; } - var chatClient = new ChatClientBuilder() + var chatClient = new ChatClientBuilder(innerClient) .UseOpenTelemetry(loggerFactory, sourceName, configure: instance => { instance.EnableSensitiveData = enableSensitiveData; instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; }) - .Use(innerClient); + .Build(); List chatMessages = [ diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ScopedChatClientExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ScopedChatClientExtensions.cs deleted file mode 100644 index d9ad92dc266..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ScopedChatClientExtensions.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; - -public static class ScopedChatClientExtensions -{ - public static ChatClientBuilder UseScopedMiddleware(this ChatClientBuilder builder) - => builder.Use((services, inner) - => new DependencyInjectionPatterns.ScopedChatClient(services, inner)); -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/SingletonChatClientExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/SingletonChatClientExtensions.cs new file mode 100644 index 00000000000..e971a0ad322 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/SingletonChatClientExtensions.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public static class SingletonChatClientExtensions +{ + public static ChatClientBuilder UseSingletonMiddleware(this ChatClientBuilder builder) + => builder.Use((services, inner) + => new DependencyInjectionPatterns.SingletonMiddleware(services, inner)); +} From aa6e8f0bbee3af799d72044c9b0c99664052fe60 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Fri, 15 Nov 2024 07:43:00 +0000 Subject: [PATCH 135/190] [main] Update dependencies from dotnet/aspnetcore (#5645) [main] Update dependencies from dotnet/aspnetcore - Coherency Updates: - Microsoft.Bcl.TimeProvider: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Caching.Abstractions: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Caching.Memory: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Configuration.Abstractions: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Configuration.Binder: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Configuration.Json: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Configuration: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.DependencyInjection.Abstractions: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.DependencyInjection: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Hosting.Abstractions: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Diagnostics: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Hosting: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Http: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Logging.Abstractions: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Logging.Configuration: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Logging.Console: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Logging: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Options.ConfigurationExtensions: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Extensions.Options: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.NETCore.App.Ref: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.Bcl.AsyncInterfaces: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.Net.Http.Json: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Microsoft.NETCore.App.Runtime.win-x64: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.Collections.Immutable: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.Configuration.ConfigurationManager: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.Diagnostics.DiagnosticSource: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.Diagnostics.PerformanceCounter: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.IO.Hashing: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.IO.Pipelines: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.Security.Cryptography.Pkcs: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.Security.Cryptography.Xml: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.Text.Encodings.Web: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.Text.Json: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - System.Runtime.Caching: from 9.0.0 to 9.0.0 (parent: Microsoft.AspNetCore.App.Runtime.win-x64) - Add missing feed - Fix versions --- NuGet.config | 8 +-- eng/Version.Details.xml | 122 ++++++++++++++++++------------------ eng/Versions.props | 18 +++--- eng/packages/TestOnly.props | 14 +---- 4 files changed, 76 insertions(+), 86 deletions(-) diff --git a/NuGet.config b/NuGet.config index f91233ccab5..549cc5b7ead 100644 --- a/NuGet.config +++ b/NuGet.config @@ -3,21 +3,21 @@ + - - - - + + + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index afdad236137..6c3afa6d940 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -2,83 +2,83 @@ https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime @@ -86,7 +86,7 @@ https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime @@ -94,7 +94,7 @@ https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 0456c7e91c34003f26acf8606ba9d20e29f518bd + 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 - - https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 592ca7fd80495bc6625c8b9d309355b6a8609861 + + https://github.com/dotnet/aspnetcore + be19faf14ebebb49e22deec138db0133990cf3ef - - https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 592ca7fd80495bc6625c8b9d309355b6a8609861 + + https://github.com/dotnet/aspnetcore + be19faf14ebebb49e22deec138db0133990cf3ef - - https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 592ca7fd80495bc6625c8b9d309355b6a8609861 + + https://github.com/dotnet/aspnetcore + be19faf14ebebb49e22deec138db0133990cf3ef - - https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 592ca7fd80495bc6625c8b9d309355b6a8609861 + + https://github.com/dotnet/aspnetcore + be19faf14ebebb49e22deec138db0133990cf3ef - - https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 592ca7fd80495bc6625c8b9d309355b6a8609861 + + https://github.com/dotnet/aspnetcore + be19faf14ebebb49e22deec138db0133990cf3ef - - https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 592ca7fd80495bc6625c8b9d309355b6a8609861 + + https://github.com/dotnet/aspnetcore + be19faf14ebebb49e22deec138db0133990cf3ef - - https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 592ca7fd80495bc6625c8b9d309355b6a8609861 + + https://github.com/dotnet/aspnetcore + be19faf14ebebb49e22deec138db0133990cf3ef - - https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 592ca7fd80495bc6625c8b9d309355b6a8609861 + + https://github.com/dotnet/aspnetcore + be19faf14ebebb49e22deec138db0133990cf3ef - - https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 592ca7fd80495bc6625c8b9d309355b6a8609861 + + https://github.com/dotnet/aspnetcore + be19faf14ebebb49e22deec138db0133990cf3ef diff --git a/eng/Versions.props b/eng/Versions.props index a8aa3286567..7ad705c103a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -64,15 +64,15 @@ 9.0.0 9.0.0 - 9.0.0 - 9.0.0 - 9.0.0 - 9.0.0 - 9.0.0 - 9.0.0 - 9.0.0 - 9.0.0 - 9.0.0 + 9.0.1 + 9.0.1 + 9.0.1 + 9.0.1 + 9.0.1 + 9.0.1 + 9.0.1 + 9.0.1 + 9.0.1 diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 4c78b8dcbe8..d9802530ed3 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -29,19 +29,9 @@ - - - - - - - - - - - - + + From 09094aebc27b03eec7656bed9c128c5cc523df39 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 15 Nov 2024 07:47:41 -0800 Subject: [PATCH 136/190] EmbeddingGeneratorBuilder API updates (#5647) --- .../README.md | 5 +- .../Microsoft.Extensions.AI.Ollama/README.md | 7 +- .../Microsoft.Extensions.AI.OpenAI/README.md | 8 +- .../ChatCompletion/ChatClientBuilder.cs | 4 +- ...lientBuilderServiceCollectionExtensions.cs | 4 +- .../Embeddings/EmbeddingGeneratorBuilder.cs | 38 +++++---- ...ratorBuilderServiceCollectionExtensions.cs | 78 +++++++++++++------ ...AzureAIInferenceEmbeddingGeneratorTests.cs | 4 +- .../EmbeddingGeneratorIntegrationTests.cs | 8 +- .../OllamaEmbeddingGeneratorTests.cs | 4 +- .../OpenAIEmbeddingGeneratorTests.cs | 8 +- ...ConfigureOptionsEmbeddingGeneratorTests.cs | 7 +- ...istributedCachingEmbeddingGeneratorTest.cs | 4 +- .../EmbeddingGeneratorBuilderTests.cs | 38 +++++---- .../LoggingEmbeddingGeneratorTests.cs | 4 +- 15 files changed, 131 insertions(+), 90 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md index f02a0eff4a6..e13709cd932 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md @@ -432,10 +432,11 @@ var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() // Explore changing the order of the intermediate "Use" calls to see that impact // that has on what gets cached, traced, etc. -IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>() +var generator = new EmbeddingGeneratorBuilder>( + new SampleEmbeddingGenerator(new Uri("http://coolsite.ai"), "my-custom-model")) .UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) .UseOpenTelemetry(sourceName) - .Use(new SampleEmbeddingGenerator(new Uri("http://coolsite.ai"), "my-custom-model")); + .Build(); var embeddings = await generator.GenerateAsync( [ diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md index 1eae652e7c8..e468965b9a8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md @@ -210,9 +210,9 @@ IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDi IEmbeddingGenerator> ollamaGenerator = new OllamaEmbeddingGenerator(new Uri("http://localhost:11434/"), "all-minilm"); -IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>() +IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>(ollamaGenerator) .UseDistributedCache(cache) - .Use(ollamaGenerator); + .Build(); foreach (var prompt in new[] { "What is AI?", "What is .NET?", "What is AI?" }) { @@ -256,8 +256,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddChatClient( new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1")); -builder.Services.AddEmbeddingGenerator>(g => - g.Use(new OllamaEmbeddingGenerator(endpoint, "all-minilm"))); +builder.Services.AddEmbeddingGenerator(new OllamaEmbeddingGenerator(endpoint, "all-minilm")); var app = builder.Build(); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md index fa0e2956e86..dacafd33a7f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md @@ -233,9 +233,9 @@ IEmbeddingGenerator> openAIGenerator = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) .AsEmbeddingGenerator("text-embedding-3-small"); -IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>() +IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>(openAIGenerator) .UseDistributedCache(cache) - .Use(openAIGenerator); + .Build(); foreach (var prompt in new[] { "What is AI?", "What is .NET?", "What is AI?" }) { @@ -284,8 +284,8 @@ builder.Services.AddSingleton(new OpenAIClient(builder.Configuration["OPENAI_API builder.Services.AddChatClient(services => services.GetRequiredService().AsChatClient("gpt-4o-mini")); -builder.Services.AddEmbeddingGenerator>(g => - g.Use(g.Services.GetRequiredService().AsEmbeddingGenerator("text-embedding-3-small"))); +builder.Services.AddEmbeddingGenerator(services => + services.GetRequiredService().AsEmbeddingGenerator("text-embedding-3-small")); var app = builder.Build(); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs index abbf4776d51..dc902c8407a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI; /// A builder for creating pipelines of . public sealed class ChatClientBuilder { - private Func _innerClientFactory; + private readonly Func _innerClientFactory; /// The registered client factory instances. private List>? _clientFactories; @@ -30,7 +30,7 @@ public ChatClientBuilder(Func innerClientFactory) _innerClientFactory = Throw.IfNull(innerClientFactory); } - /// Returns an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. + /// Builds an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. /// /// The that should provide services to the instances. /// If null, an empty will be used. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs index a057a507f24..c3d8ab88edb 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderServiceCollectionExtensions.cs @@ -37,7 +37,7 @@ public static ChatClientBuilder AddChatClient( return builder; } - /// Registers a singleton in the . + /// Registers a keyed singleton in the . /// The to which the client should be added. /// The key with which to associate the client. /// The inner that represents the underlying backend. @@ -49,7 +49,7 @@ public static ChatClientBuilder AddKeyedChatClient( IChatClient innerClient) => AddKeyedChatClient(serviceCollection, serviceKey, _ => innerClient); - /// Registers a singleton in the . + /// Registers a keyed singleton in the . /// The to which the client should be added. /// The key with which to associate the client. /// A callback that produces the inner that represents the underlying backend. diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs index 96c4c92d4a9..7983ca495bf 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs @@ -13,39 +13,45 @@ namespace Microsoft.Extensions.AI; public sealed class EmbeddingGeneratorBuilder where TEmbedding : Embedding { + private readonly Func> _innerGeneratorFactory; + /// The registered client factory instances. private List, IEmbeddingGenerator>>? _generatorFactories; /// Initializes a new instance of the class. - /// The service provider to use for dependency injection. - public EmbeddingGeneratorBuilder(IServiceProvider? services = null) + /// The inner that represents the underlying backend. + public EmbeddingGeneratorBuilder(IEmbeddingGenerator innerGenerator) { - Services = services ?? EmptyServiceProvider.Instance; + _ = Throw.IfNull(innerGenerator); + _innerGeneratorFactory = _ => innerGenerator; } - /// Gets the associated with the builder instance. - public IServiceProvider Services { get; } + /// Initializes a new instance of the class. + /// A callback that produces the inner that represents the underlying backend. + public EmbeddingGeneratorBuilder(Func> innerGeneratorFactory) + { + _innerGeneratorFactory = Throw.IfNull(innerGeneratorFactory); + } /// - /// Builds an instance of using the specified inner generator. + /// Builds an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. /// - /// The inner generator to use. - /// An instance of . - /// - /// If there are any factories registered with this builder, is used as a seed to - /// the last factory, and the result of each factory delegate is passed to the previously registered factory. - /// The final result is then returned from this call. - /// - public IEmbeddingGenerator Use(IEmbeddingGenerator innerGenerator) + /// + /// The that should provide services to the instances. + /// If null, an empty will be used. + /// + /// An instance of that represents the entire pipeline. + public IEmbeddingGenerator Build(IServiceProvider? services = null) { - var embeddingGenerator = Throw.IfNull(innerGenerator); + services ??= EmptyServiceProvider.Instance; + var embeddingGenerator = _innerGeneratorFactory(services); // To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost. if (_generatorFactories is not null) { for (var i = _generatorFactories.Count - 1; i >= 0; i--) { - embeddingGenerator = _generatorFactories[i](Services, embeddingGenerator) ?? + embeddingGenerator = _generatorFactories[i](services, embeddingGenerator) ?? throw new InvalidOperationException( $"The {nameof(IEmbeddingGenerator)} entry at index {i} returned null. " + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IEmbeddingGenerator)} instances."); diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs index 4f2eddf6b1b..1c57fb08215 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderServiceCollectionExtensions.cs @@ -10,44 +10,74 @@ namespace Microsoft.Extensions.DependencyInjection; /// Provides extension methods for registering with a . public static class EmbeddingGeneratorBuilderServiceCollectionExtensions { - /// Adds a embedding generator to the . + /// Registers a singleton embedding generator in the . /// The type from which embeddings will be generated. /// The type of embeddings to generate. - /// The to which the generator should be added. - /// The factory to use to construct the instance. - /// The collection. - /// The generator is registered as a scoped service. - public static IServiceCollection AddEmbeddingGenerator( - this IServiceCollection services, - Func, IEmbeddingGenerator> generatorFactory) + /// The to which the generator should be added. + /// The inner that represents the underlying backend. + /// An that can be used to build a pipeline around the inner generator. + /// The generator is registered as a singleton service. + public static EmbeddingGeneratorBuilder AddEmbeddingGenerator( + this IServiceCollection serviceCollection, + IEmbeddingGenerator innerGenerator) + where TEmbedding : Embedding + => AddEmbeddingGenerator(serviceCollection, _ => innerGenerator); + + /// Registers a singleton embedding generator in the . + /// The type from which embeddings will be generated. + /// The type of embeddings to generate. + /// The to which the generator should be added. + /// A callback that produces the inner that represents the underlying backend. + /// An that can be used to build a pipeline around the inner generator. + /// The generator is registered as a singleton service. + public static EmbeddingGeneratorBuilder AddEmbeddingGenerator( + this IServiceCollection serviceCollection, + Func> innerGeneratorFactory) where TEmbedding : Embedding { - _ = Throw.IfNull(services); - _ = Throw.IfNull(generatorFactory); + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerGeneratorFactory); - return services.AddScoped(services => - generatorFactory(new EmbeddingGeneratorBuilder(services))); + var builder = new EmbeddingGeneratorBuilder(innerGeneratorFactory); + _ = serviceCollection.AddSingleton(builder.Build); + return builder; } - /// Adds an embedding generator to the . + /// Registers a keyed singleton embedding generator in the . + /// The type from which embeddings will be generated. + /// The type of embeddings to generate. + /// The to which the generator should be added. + /// The key with which to associated the generator. + /// The inner that represents the underlying backend. + /// An that can be used to build a pipeline around the inner generator. + /// The generator is registered as a singleton service. + public static EmbeddingGeneratorBuilder AddKeyedEmbeddingGenerator( + this IServiceCollection serviceCollection, + object serviceKey, + IEmbeddingGenerator innerGenerator) + where TEmbedding : Embedding + => AddKeyedEmbeddingGenerator(serviceCollection, serviceKey, _ => innerGenerator); + + /// Registers a keyed singleton embedding generator in the . /// The type from which embeddings will be generated. /// The type of embeddings to generate. - /// The to which the service should be added. + /// The to which the generator should be added. /// The key with which to associated the generator. - /// The factory to use to construct the instance. - /// The collection. - /// The generator is registered as a scoped service. - public static IServiceCollection AddKeyedEmbeddingGenerator( - this IServiceCollection services, + /// A callback that produces the inner that represents the underlying backend. + /// An that can be used to build a pipeline around the inner generator. + /// The generator is registered as a singleton service. + public static EmbeddingGeneratorBuilder AddKeyedEmbeddingGenerator( + this IServiceCollection serviceCollection, object serviceKey, - Func, IEmbeddingGenerator> generatorFactory) + Func> innerGeneratorFactory) where TEmbedding : Embedding { - _ = Throw.IfNull(services); + _ = Throw.IfNull(serviceCollection); _ = Throw.IfNull(serviceKey); - _ = Throw.IfNull(generatorFactory); + _ = Throw.IfNull(innerGeneratorFactory); - return services.AddKeyedScoped(serviceKey, (services, _) => - generatorFactory(new EmbeddingGeneratorBuilder(services))); + var builder = new EmbeddingGeneratorBuilder(innerGeneratorFactory); + _ = serviceCollection.AddKeyedSingleton(serviceKey, (services, _) => builder.Build(services)); + return builder; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs index abd5f609ed2..843766515b2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs @@ -63,10 +63,10 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.Same(embeddingGenerator, embeddingGenerator.GetService>>()); Assert.Same(client, embeddingGenerator.GetService()); - using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>() + using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>(embeddingGenerator) .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Use(embeddingGenerator); + .Build(); Assert.NotNull(pipeline.GetService>>()); Assert.NotNull(pipeline.GetService>>()); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs index 70eb6a31283..7ba3878385b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs @@ -81,10 +81,10 @@ public virtual async Task Caching_SameOutputsForSameInput() { SkipIfNotEnabled(); - using var generator = new EmbeddingGeneratorBuilder>() + using var generator = new EmbeddingGeneratorBuilder>(CreateEmbeddingGenerator()!) .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .UseCallCounting() - .Use(CreateEmbeddingGenerator()!); + .Build(); string input = "Red, White, and Blue"; var embedding1 = await generator.GenerateEmbeddingAsync(input); @@ -110,9 +110,9 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() .AddInMemoryExporter(activities) .Build(); - var embeddingGenerator = new EmbeddingGeneratorBuilder>() + var embeddingGenerator = new EmbeddingGeneratorBuilder>(CreateEmbeddingGenerator()!) .UseOpenTelemetry(sourceName: sourceName) - .Use(CreateEmbeddingGenerator()!); + .Build(); _ = await embeddingGenerator.GenerateEmbeddingAsync("Hello, world!"); diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs index 541aab244fe..6dd8b82d986 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs @@ -29,10 +29,10 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.Same(generator, generator.GetService()); Assert.Same(generator, generator.GetService>>()); - using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>() + using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>(generator) .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Use(generator); + .Build(); Assert.NotNull(pipeline.GetService>>()); Assert.NotNull(pipeline.GetService>>()); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index 50b64fc9196..37a45f93441 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -78,10 +78,10 @@ public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() Assert.NotNull(embeddingGenerator.GetService()); - using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>() + using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>(embeddingGenerator) .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Use(embeddingGenerator); + .Build(); Assert.NotNull(pipeline.GetService>>()); Assert.NotNull(pipeline.GetService>>()); @@ -100,10 +100,10 @@ public void GetService_EmbeddingClient_SuccessfullyReturnsUnderlyingClient() Assert.Same(embeddingGenerator, embeddingGenerator.GetService>>()); Assert.Same(openAIClient, embeddingGenerator.GetService()); - using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>() + using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>(embeddingGenerator) .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Use(embeddingGenerator); + .Build(); Assert.NotNull(pipeline.GetService>>()); Assert.NotNull(pipeline.GetService>>()); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs index 70674646bd1..ecb96c993ea 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs @@ -20,7 +20,8 @@ public void ConfigureOptionsEmbeddingGenerator_InvalidArgs_Throws() [Fact] public void ConfigureOptions_InvalidArgs_Throws() { - var builder = new EmbeddingGeneratorBuilder>(); + using var innerGenerator = new TestEmbeddingGenerator(); + var builder = new EmbeddingGeneratorBuilder>(innerGenerator); Assert.Throws("configure", () => builder.ConfigureOptions(null!)); } @@ -44,7 +45,7 @@ public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullP } }; - using var generator = new EmbeddingGeneratorBuilder>() + using var generator = new EmbeddingGeneratorBuilder>(innerGenerator) .ConfigureOptions(options => { Assert.NotSame(providedOptions, options); @@ -59,7 +60,7 @@ public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullP returnedOptions = options; }) - .Use(innerGenerator); + .Build(); var embeddings = await generator.GenerateAsync([], providedOptions, cts.Token); Assert.Same(expectedEmbeddings, embeddings); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs index f9356ef45c9..55cc206ebfc 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs @@ -321,12 +321,12 @@ public async Task CanResolveIDistributedCacheFromDI() return Task.FromResult>>([_expectedEmbedding]); }, }; - using var outer = new EmbeddingGeneratorBuilder>(services) + using var outer = new EmbeddingGeneratorBuilder>(testGenerator) .UseDistributedCache(configure: instance => { instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; }) - .Use(testGenerator); + .Build(services); // Act: Make a request that should populate the cache Assert.Empty(_storage.Keys); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs index 357168c3b65..b25044992e8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs @@ -13,32 +13,37 @@ public class EmbeddingGeneratorBuilderTests public void PassesServiceProviderToFactories() { var expectedServiceProvider = new ServiceCollection().BuildServiceProvider(); - using var expectedResult = new TestEmbeddingGenerator(); - var builder = new EmbeddingGeneratorBuilder>(expectedServiceProvider); + using var expectedOuterGenerator = new TestEmbeddingGenerator(); + using var expectedInnerGenerator = new TestEmbeddingGenerator(); - builder.Use((serviceProvider, innerClient) => + var builder = new EmbeddingGeneratorBuilder>(services => { - Assert.Same(expectedServiceProvider, serviceProvider); - return expectedResult; + Assert.Same(expectedServiceProvider, services); + return expectedInnerGenerator; }); - using var innerGenerator = new TestEmbeddingGenerator(); - Assert.Equal(expectedResult, builder.Use(innerGenerator)); + builder.Use((services, innerClient) => + { + Assert.Same(expectedServiceProvider, services); + return expectedOuterGenerator; + }); + + Assert.Equal(expectedOuterGenerator, builder.Build(expectedServiceProvider)); } [Fact] public void BuildsPipelineInOrderAdded() { // Arrange - using var expectedInnerService = new TestEmbeddingGenerator(); - var builder = new EmbeddingGeneratorBuilder>(); + using var expectedInnerGenerator = new TestEmbeddingGenerator(); + var builder = new EmbeddingGeneratorBuilder>(expectedInnerGenerator); builder.Use(next => new InnerServiceCapturingEmbeddingGenerator("First", next)); builder.Use(next => new InnerServiceCapturingEmbeddingGenerator("Second", next)); builder.Use(next => new InnerServiceCapturingEmbeddingGenerator("Third", next)); // Act - var first = (InnerServiceCapturingEmbeddingGenerator)builder.Use(expectedInnerService); + var first = (InnerServiceCapturingEmbeddingGenerator)builder.Build(); // Assert Assert.Equal("First", first.Name); @@ -46,29 +51,28 @@ public void BuildsPipelineInOrderAdded() Assert.Equal("Second", second.Name); var third = (InnerServiceCapturingEmbeddingGenerator)second.InnerGenerator; Assert.Equal("Third", third.Name); - Assert.Same(expectedInnerService, third.InnerGenerator); + Assert.Same(expectedInnerGenerator, third.InnerGenerator); } [Fact] public void DoesNotAcceptNullInnerService() { - Assert.Throws(() => new EmbeddingGeneratorBuilder>().Use((IEmbeddingGenerator>)null!)); + Assert.Throws(() => new EmbeddingGeneratorBuilder>((IEmbeddingGenerator>)null!)); } [Fact] public void DoesNotAcceptNullFactories() { - var builder = new EmbeddingGeneratorBuilder>(); - Assert.Throws(() => builder.Use((Func>, IEmbeddingGenerator>>)null!)); - Assert.Throws(() => builder.Use((Func>, IEmbeddingGenerator>>)null!)); + Assert.Throws(() => new EmbeddingGeneratorBuilder>((Func>>)null!)); } [Fact] public void DoesNotAllowFactoriesToReturnNull() { - var builder = new EmbeddingGeneratorBuilder>(); + using var innerGenerator = new TestEmbeddingGenerator(); + var builder = new EmbeddingGeneratorBuilder>(innerGenerator); builder.Use(_ => null!); - var ex = Assert.Throws(() => builder.Use(new TestEmbeddingGenerator())); + var ex = Assert.Throws(() => builder.Build()); Assert.Contains("entry at index 0", ex.Message); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs index 5cd6267eb74..b8a342e5f73 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs @@ -39,9 +39,9 @@ public async Task CompleteAsync_LogsStartAndCompletion(LogLevel level) }, }; - using IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>(services) + using IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>(innerGenerator) .UseLogging() - .Use(innerGenerator); + .Build(services); await generator.GenerateEmbeddingAsync("Blue whale"); From 7c398375c20dfd8af13e3441164ed7d2c1313b46 Mon Sep 17 00:00:00 2001 From: Amadeusz Lechniak Date: Sat, 16 Nov 2024 02:49:02 +0100 Subject: [PATCH 137/190] Update WaiterRemovedAfterDispose to check waitersCount first (#5646) Co-authored-by: EUROPE\alechniak --- .../TimerTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs index 0fc6f2d9b8b..c0046cc0429 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs @@ -226,15 +226,18 @@ public void WaiterRemovedAfterDispose() timer1.Dispose(); + var waitersCountAfterDispose = timeProvider.Waiters.Count; + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); - var waitersCountAfter = timeProvider.Waiters.Count; + var waitersCountOnFinish = timeProvider.Waiters.Count; Assert.Equal(0, waitersCountStart); Assert.Equal(2, waitersCountDuring); + Assert.Equal(1, waitersCountAfterDispose); + Assert.Equal(1, waitersCountOnFinish); Assert.Equal(1, timer1Counter); Assert.Equal(2, timer2Counter); - Assert.Equal(1, waitersCountAfter); } #if RELEASE // In Release only since this might not work if the timer reference being tracked by the debugger From 38e7a1a45d7638c234cb42da3f211cb503cee0aa Mon Sep 17 00:00:00 2001 From: Darius Letterman Date: Mon, 18 Nov 2024 11:24:41 +0100 Subject: [PATCH 138/190] Allow logging of body without modifying the actual response (#5628) Allow logging of body without modifying the actual response --- .../Logging/Internal/Constants.cs | 2 +- .../Logging/Internal/HttpRequestBodyReader.cs | 2 +- .../Internal/HttpResponseBodyReader.cs | 229 +++++++++++++----- ...crosoft.Extensions.Http.Diagnostics.csproj | 2 +- .../Logging/AcceptanceTests.cs | 43 ++-- .../Logging/HttpRequestBodyReaderTest.cs | 2 +- .../Logging/HttpResponseBodyReaderTest.cs | 124 ++++++++-- 7 files changed, 301 insertions(+), 103 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Constants.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Constants.cs index 748dff5aa20..433d6faa3ea 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Constants.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Constants.cs @@ -7,5 +7,5 @@ internal static class Constants { public const string NoContent = "[no-content-type]"; public const string UnreadableContent = "[unreadable-content-type]"; - public const string ReadCancelled = "[read-cancelled]"; + public const string ReadCancelledByTimeout = "[read-timeout]"; } 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 38ed1c57378..ed5a3c3f33d 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs @@ -79,7 +79,7 @@ private static async ValueTask ReadFromStreamWithTimeoutAsync(HttpReques // when readTimeout occurred: catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - return Constants.ReadCancelled; + return Constants.ReadCancelledByTimeout; } } 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 9235603767d..0c5b6a672b1 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs @@ -3,15 +3,15 @@ using System; using System.Collections.Frozen; +using System.Collections.Generic; using System.IO; +using System.IO.Pipelines; using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.ObjectPool; -using Microsoft.IO; using Microsoft.Shared.Diagnostics; -using Microsoft.Shared.Pools; namespace Microsoft.Extensions.Http.Logging.Internal; @@ -22,15 +22,18 @@ internal sealed class HttpResponseBodyReader /// internal readonly TimeSpan ResponseReadTimeout; - private static readonly ObjectPool> _bufferWriterPool = BufferWriterPool.SharedBufferWriterPool; + // The chunk size of 8192 bytes (8 KB) is chosen as a balance between memory usage and performance. + // It is large enough to efficiently handle typical HTTP response sizes without excessive memory allocation, + // while still being small enough to avoid large object heap allocations and reduce memory fragmentation. + private const int ChunkSize = 8 * 1024; + private readonly FrozenSet _readableResponseContentTypes; private readonly int _responseReadLimit; - private readonly RecyclableMemoryStreamManager _streamManager; - public HttpResponseBodyReader(LoggingOptions responseOptions, IDebuggerState? debugger = null) { - _streamManager = new RecyclableMemoryStreamManager(); + _ = Throw.IfNull(responseOptions); + _readableResponseContentTypes = responseOptions.ResponseBodyContentTypes.ToFrozenSet(StringComparer.OrdinalIgnoreCase); _responseReadLimit = responseOptions.BodySizeLimit; @@ -43,7 +46,7 @@ public HttpResponseBodyReader(LoggingOptions responseOptions, IDebuggerState? de public ValueTask ReadAsync(HttpResponseMessage response, CancellationToken cancellationToken) { - var contentType = response.Content.Headers.ContentType; + MediaTypeHeaderValue? contentType = response.Content.Headers.ContentType; if (contentType == null) { return new(Constants.NoContent); @@ -54,90 +57,186 @@ public ValueTask ReadAsync(HttpResponseMessage response, CancellationTok return new(Constants.UnreadableContent); } - return ReadFromStreamWithTimeoutAsync(response, ResponseReadTimeout, _responseReadLimit, _streamManager, - cancellationToken).Preserve(); + return ReadFromStreamWithTimeoutAsync(response, ResponseReadTimeout, _responseReadLimit, cancellationToken).Preserve(); } - private static async ValueTask ReadFromStreamAsync(HttpResponseMessage response, int readSizeLimit, - RecyclableMemoryStreamManager streamManager, CancellationToken cancellationToken) + private static async ValueTask ReadFromStreamWithTimeoutAsync(HttpResponseMessage response, TimeSpan readTimeout, int readSizeLimit, CancellationToken cancellationToken) { -#if NET5_0_OR_GREATER - var streamToReadFrom = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); -#else - var streamToReadFrom = await response.Content.ReadAsStreamAsync().WaitAsync(cancellationToken).ConfigureAwait(false); -#endif + using var joinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + joinedTokenSource.CancelAfter(readTimeout); + + // TimeSpan.Zero cannot be set from user's code as + // validation prevents values less than one millisecond + // However, this is useful during unit tests + if (readTimeout <= TimeSpan.Zero) + { + // cancel immediately, async cancel not required in tests +#pragma warning disable CA1849 // Call async methods when in an async method + joinedTokenSource.Cancel(); +#pragma warning restore CA1849 // Call async methods when in an async method + } - var bufferWriter = _bufferWriterPool.Get(); - var memory = bufferWriter.GetMemory(readSizeLimit).Slice(0, readSizeLimit); -#if !NETCOREAPP3_1_OR_GREATER - byte[] buffer = memory.ToArray(); -#endif try { -#if NETCOREAPP3_1_OR_GREATER - var charsWritten = await streamToReadFrom.ReadAsync(memory, cancellationToken).ConfigureAwait(false); - bufferWriter.Advance(charsWritten); - return Encoding.UTF8.GetString(memory.Slice(0, charsWritten).Span); + return await ReadFromStreamAsync(response, readSizeLimit, joinedTokenSource.Token).ConfigureAwait(false); + } + + // when readTimeout occurred: joined token source is cancelled and cancellationToken is not + catch (OperationCanceledException) when (joinedTokenSource.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + return Constants.ReadCancelledByTimeout; + } + } + + private static async ValueTask ReadFromStreamAsync(HttpResponseMessage response, int readSizeLimit, CancellationToken cancellationToken) + { +#if NET6_0_OR_GREATER + Stream streamToReadFrom = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); #else - var charsWritten = await streamToReadFrom.ReadAsync(buffer, 0, readSizeLimit, cancellationToken).ConfigureAwait(false); - bufferWriter.Advance(charsWritten); - return Encoding.UTF8.GetString(buffer.AsMemory(0, charsWritten).ToArray()); + Stream streamToReadFrom = await response.Content.ReadAsStreamAsync().WaitAsync(cancellationToken).ConfigureAwait(false); #endif + + var pipe = new Pipe(); + + string bufferedString = await BufferStreamAndWriteToPipeAsync(streamToReadFrom, pipe.Writer, readSizeLimit, cancellationToken).ConfigureAwait(false); + + // if stream is seekable we can just rewind it and return the buffered string + if (streamToReadFrom.CanSeek) + { + streamToReadFrom.Seek(0, SeekOrigin.Begin); + + await pipe.Reader.CompleteAsync().ConfigureAwait(false); + + return bufferedString; } - finally + + // 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 () => { - if (streamToReadFrom.CanSeek) + await WriteStreamToPipeAsync(streamToReadFrom, pipe.Writer, cancellationToken).ConfigureAwait(false); + }, CancellationToken.None); + + // use the pipe reader as stream for the new content + var newContent = new StreamContent(pipe.Reader.AsStream()); + foreach (KeyValuePair> header in response.Content.Headers) + { + _ = newContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + response.Content = newContent; + + return bufferedString; + } + +#if NET6_0_OR_GREATER + private static async Task 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) { - streamToReadFrom.Seek(0, SeekOrigin.Begin); + break; } - else - { - var freshStream = streamManager.GetStream(); -#if NETCOREAPP3_1_OR_GREATER - var remainingSpace = memory.Slice(bufferWriter.WrittenCount, memory.Length - bufferWriter.WrittenCount); - var writtenCount = await streamToReadFrom.ReadAsync(remainingSpace, cancellationToken) - .ConfigureAwait(false); - - await freshStream.WriteAsync(memory.Slice(0, writtenCount + bufferWriter.WrittenCount), cancellationToken) - .ConfigureAwait(false); -#else - var writtenCount = await streamToReadFrom.ReadAsync(buffer, bufferWriter.WrittenCount, - buffer.Length - bufferWriter.WrittenCount, cancellationToken).ConfigureAwait(false); - await freshStream.WriteAsync(buffer, 0, writtenCount + bufferWriter.WrittenCount, cancellationToken).ConfigureAwait(false); + bytesRead += read; + } #endif - freshStream.Seek(0, SeekOrigin.Begin); - var newContent = new StreamContent(freshStream); + if (bytesRead == 0) + { + return string.Empty; + } + + writer.Advance(bytesRead); + + return Encoding.UTF8.GetString(memory[..bytesRead].Span); + } - foreach (var header in response.Content.Headers) - { - _ = newContent.Headers.TryAddWithoutValidation(header.Key, header.Value); - } + private static async Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) + { + while (true) + { + Memory memory = writer.GetMemory(ChunkSize)[..ChunkSize]; - response.Content = newContent; + int bytesRead = await stream.ReadAsync(memory, cancellationToken).ConfigureAwait(false); + if (bytesRead == 0) + { + break; } - _bufferWriterPool.Return(bufferWriter); + writer.Advance(bytesRead); + + FlushResult result = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + if (result.IsCompleted) + { + break; + } } - } - private static async ValueTask ReadFromStreamWithTimeoutAsync(HttpResponseMessage response, TimeSpan readTimeout, - int readSizeLimit, RecyclableMemoryStreamManager streamManager, CancellationToken cancellationToken) + await writer.CompleteAsync().ConfigureAwait(false); + } +#else + private static async Task BufferStreamAndWriteToPipeAsync(Stream stream, PipeWriter writer, int bufferSize, CancellationToken cancellationToken) { - using var joinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - joinedTokenSource.CancelAfter(readTimeout); + var sb = new StringBuilder(); - try + int bytesRead = 0; + + while (bytesRead < bufferSize) { - return await ReadFromStreamAsync(response, readSizeLimit, streamManager, joinedTokenSource.Token) - .ConfigureAwait(false); + int chunkSize = Math.Min(ChunkSize, bufferSize - bytesRead); + + Memory memory = writer.GetMemory(chunkSize).Slice(0, chunkSize); + + byte[] buffer = memory.ToArray(); + + int read = await stream.ReadAsync(buffer, 0, chunkSize, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + + bytesRead += read; + + buffer.CopyTo(memory); + + writer.Advance(read); + + _ = sb.Append(Encoding.UTF8.GetString(buffer.AsMemory(0, read).ToArray())); } - // when readTimeout occurred: - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + return sb.ToString(); + } + + private static async Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) + { + while (true) { - return Constants.ReadCancelled; + Memory memory = writer.GetMemory(ChunkSize).Slice(0, ChunkSize); + byte[] buffer = memory.ToArray(); + + int bytesRead = await stream.ReadAsync(buffer, 0, ChunkSize, cancellationToken).ConfigureAwait(false); + if (bytesRead == 0) + { + break; + } + + FlushResult result = await writer.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); + if (result.IsCompleted) + { + break; + } } + + await writer.CompleteAsync().ConfigureAwait(false); } +#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 f6c98baefce..cc5de094e47 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 @@ -38,7 +38,7 @@ - + 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 9ae4ee7bd88..3143aab9185 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs @@ -171,9 +171,9 @@ public async Task AddHttpClientLogging_WithNamedHttpClients_WorksCorrectly() var collector = provider.GetFakeLogCollector(); var logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory); var state = logRecord.StructuredState; - state.Should().Contain(kvp => kvp.Value == responseString); - state.Should().Contain(kvp => kvp.Value == "Request Value"); - state.Should().Contain(kvp => kvp.Value == "Request Value 2,Request Value 3"); + state.Should().ContainValue(responseString); + state.Should().ContainValue("Request Value"); + state.Should().ContainValue("Request Value 2,Request Value 3"); using var httpRequestMessage2 = new HttpRequestMessage { @@ -187,9 +187,9 @@ public async Task AddHttpClientLogging_WithNamedHttpClients_WorksCorrectly() responseString = await SendRequest(namedClient2, httpRequestMessage2); logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory); state = logRecord.StructuredState; - state.Should().Contain(kvp => kvp.Value == responseString); - state.Should().Contain(kvp => kvp.Value == "Request Value"); - state.Should().Contain(kvp => kvp.Value == "Request Value 2,Request Value 3"); + state.Should().ContainValue(responseString); + state.Should().ContainValue("Request Value"); + state.Should().ContainValue("Request Value 2,Request Value 3"); } private static async Task SendRequest(HttpClient httpClient, HttpRequestMessage httpRequestMessage) @@ -258,9 +258,9 @@ public async Task AddHttpClientLogging_WithTypedHttpClients_WorksCorrectly() var logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory); var state = logRecord.StructuredState; state.Should().NotBeNull(); - state.Should().Contain(kvp => kvp.Value == responseString); - state.Should().Contain(kvp => kvp.Value == "Request Value"); - state.Should().Contain(kvp => kvp.Value == "Request Value 2,Request Value 3"); + state.Should().ContainValue(responseString); + state.Should().ContainValue("Request Value"); + state.Should().ContainValue("Request Value 2,Request Value 3"); using var httpRequestMessage2 = new HttpRequestMessage { @@ -279,9 +279,9 @@ public async Task AddHttpClientLogging_WithTypedHttpClients_WorksCorrectly() logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory); state = logRecord.StructuredState; - state.Should().Contain(kvp => kvp.Value == responseString); - state.Should().Contain(kvp => kvp.Value == "Request Value"); - state.Should().Contain(kvp => kvp.Value == "Request Value 2,Request Value 3"); + state.Should().ContainValue(responseString); + state.Should().ContainValue("Request Value"); + state.Should().ContainValue("Request Value 2,Request Value 3"); } [Theory] @@ -654,6 +654,8 @@ public async Task AddDefaultHttpClientLogging_DisablesNetScope() [InlineData(315_883)] public async Task HttpClientLoggingHandler_LogsBodyDataUpToSpecifiedLimit(int limit) { + const int LengthOfContentInTextFile = 64_751; + await using var provider = new ServiceCollection() .AddFakeLogging() .AddFakeRedaction() @@ -686,17 +688,18 @@ public async Task HttpClientLoggingHandler_LogsBodyDataUpToSpecifiedLimit(int li httpRequestMessage.Headers.Add("ReQuEStHeAdEr2", new List { "Request Value 2", "Request Value 3" }); var content = await client.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead); - var responseStream = await content.Content.ReadAsStreamAsync(); - var length = (int)responseStream.Length > limit ? limit : (int)responseStream.Length; - var buffer = new byte[length]; - _ = await responseStream.ReadAsync(buffer, 0, length); - var responseString = Encoding.UTF8.GetString(buffer); + var responseString = await content.Content.ReadAsStringAsync(); + var length = Math.Min(limit, responseString.Length); + var loggedBodyString = responseString.Substring(0, length); + + // length of the content in the Text.txt file + responseString.Length.Should().Be(LengthOfContentInTextFile); var collector = provider.GetFakeLogCollector(); var logRecord = collector.GetSnapshot().Single(l => l.Category == LoggingCategory); var state = logRecord.StructuredState; - state.Should().Contain(kvp => kvp.Value == responseString); - state.Should().Contain(kvp => kvp.Value == "Request Value"); - state.Should().Contain(kvp => kvp.Value == "Request Value 2,Request Value 3"); + state.Should().ContainValue(loggedBodyString); + state.Should().ContainValue("Request Value"); + state.Should().ContainValue("Request Value 2,Request Value 3"); } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestBodyReaderTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestBodyReaderTest.cs index 9282e9a4838..f95d16f2afc 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestBodyReaderTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestBodyReaderTest.cs @@ -193,7 +193,7 @@ public async Task Reader_ReadingTakesTooLong_Timesout() var requestBody = await httpRequestBodyReader.ReadAsync(httpRequest, CancellationToken.None); var returnedValue = requestBody; - var expectedValue = Constants.ReadCancelled; + var expectedValue = Constants.ReadCancelledByTimeout; returnedValue.Should().BeEquivalentTo(expectedValue); } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpResponseBodyReaderTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpResponseBodyReaderTest.cs index c23568ddf80..ec78df392fc 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpResponseBodyReaderTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpResponseBodyReaderTest.cs @@ -20,6 +20,7 @@ namespace Microsoft.Extensions.Http.Logging.Test; public class HttpResponseBodyReaderTest { + private const string TextPlain = "text/plain"; private readonly Fixture _fixture; public HttpResponseBodyReaderTest() @@ -27,19 +28,26 @@ public HttpResponseBodyReaderTest() _fixture = new Fixture(); } + [Fact] + public void Reader_NullOptions_Throws() + { + var act = () => new HttpResponseBodyReader(null!); + act.Should().Throw(); + } + [Fact] public async Task Reader_SimpleContent_ReadsContent() { var options = new LoggingOptions { - ResponseBodyContentTypes = new HashSet { "text/plain" } + ResponseBodyContentTypes = new HashSet { TextPlain } }; var httpResponseBodyReader = new HttpResponseBodyReader(options); var expectedContentBody = _fixture.Create(); using var httpResponse = new HttpResponseMessage { - Content = new StringContent(expectedContentBody, Encoding.UTF8, "text/plain") + Content = new StringContent(expectedContentBody, Encoding.UTF8, TextPlain) }; var responseBody = await httpResponseBodyReader.ReadAsync(httpResponse, CancellationToken.None); @@ -48,11 +56,11 @@ public async Task Reader_SimpleContent_ReadsContent() } [Fact] - public async Task Reader_EmptyContent_ErrorMessage() + public async Task Reader_NoContentType_ErrorMessage() { var options = new LoggingOptions { - ResponseBodyContentTypes = new HashSet { "text/plain" } + ResponseBodyContentTypes = new HashSet { TextPlain } }; using var httpResponse = new HttpResponseMessage @@ -66,6 +74,24 @@ public async Task Reader_EmptyContent_ErrorMessage() responseBody.Should().Be(Constants.NoContent); } + [Fact] + public async Task Reader_EmptyContent_ReturnsEmptyString() + { + var options = new LoggingOptions + { + ResponseBodyContentTypes = new HashSet { TextPlain } + }; + using var httpResponse = new HttpResponseMessage + { + Content = new StringContent(string.Empty, Encoding.UTF8, TextPlain) + }; + + var httpResponseBodyReader = new HttpResponseBodyReader(options); + var responseBody = await httpResponseBodyReader.ReadAsync(httpResponse, CancellationToken.None); + + responseBody.Should().BeEmpty(); + } + [Theory] [CombinatorialData] public async Task Reader_UnreadableContent_ErrorMessage( @@ -75,7 +101,7 @@ public async Task Reader_UnreadableContent_ErrorMessage( { var options = new LoggingOptions { - ResponseBodyContentTypes = new HashSet { "text/plain" } + ResponseBodyContentTypes = new HashSet { TextPlain } }; var httpResponseBodyReader = new HttpResponseBodyReader(options); @@ -95,14 +121,14 @@ public async Task Reader_OperationCanceled_ThrowsTaskCanceledException() { var options = new LoggingOptions { - ResponseBodyContentTypes = new HashSet { "text/plain" } + ResponseBodyContentTypes = new HashSet { TextPlain } }; var httpResponseBodyReader = new HttpResponseBodyReader(options); var input = _fixture.Create(); using var httpResponse = new HttpResponseMessage { - Content = new StringContent(input, Encoding.UTF8, "text/plain") + Content = new StringContent(input, Encoding.UTF8, TextPlain) }; var token = new CancellationToken(true); @@ -119,19 +145,60 @@ public async Task Reader_BigContent_TrimsAtTheEnd([CombinatorialValues(32, 256, var options = new LoggingOptions { BodySizeLimit = limit, - ResponseBodyContentTypes = new HashSet { "text/plain" } + ResponseBodyContentTypes = new HashSet { TextPlain } }; var httpResponseBodyReader = new HttpResponseBodyReader(options); var bigContent = RandomStringGenerator.Generate(limit * 2); using var httpResponse = new HttpResponseMessage { - Content = new StringContent(bigContent, Encoding.UTF8, "text/plain") + Content = new StreamContent(new NotSeekableStream(new(Encoding.UTF8.GetBytes(bigContent)))) }; + httpResponse.Content.Headers.Add("Content-Type", TextPlain); var responseBody = await httpResponseBodyReader.ReadAsync(httpResponse, CancellationToken.None); responseBody.Should().Be(bigContent.Substring(0, limit)); + + // This should read from piped stream + var response = await httpResponse.Content.ReadAsStringAsync(); + + response.Should().Be(bigContent); + } + + [Fact] + public async Task Reader_ReaderCancelledAfterBuffering_ShouldCancelPipeReader() + { + const int BodySize = 10_000_000; + var options = new LoggingOptions + { + BodySizeLimit = 1, + ResponseBodyContentTypes = new HashSet { TextPlain } + }; + var httpResponseBodyReader = new HttpResponseBodyReader(options); + var bigContent = RandomStringGenerator.Generate(BodySize); + using var httpResponse = new HttpResponseMessage + { + Content = new StreamContent(new NotSeekableStream(new(Encoding.UTF8.GetBytes(bigContent)))) + }; + httpResponse.Content.Headers.Add("Content-Type", TextPlain); + + using var cts = new CancellationTokenSource(); + + var responseBody = await httpResponseBodyReader.ReadAsync(httpResponse, cts.Token); + + responseBody.Should().HaveLength(1); + + // This should read from piped stream + var responseStream = await httpResponse.Content.ReadAsStreamAsync(); + + var buffer = new byte[BodySize]; + + cts.Cancel(false); + + var act = async () => await responseStream.ReadAsync(buffer, 0, BodySize, cts.Token); + + await act.Should().ThrowAsync().Where(e => e.CancellationToken.IsCancellationRequested); } [Fact] @@ -139,12 +206,13 @@ public async Task Reader_ReadingTakesTooLong_TimesOut() { var options = new LoggingOptions { - ResponseBodyContentTypes = new HashSet { "text/plain" } + ResponseBodyContentTypes = new HashSet { TextPlain }, + BodyReadTimeout = TimeSpan.Zero }; var httpResponseBodyReader = new HttpResponseBodyReader(options); var streamMock = new Mock(); -#if NETCOREAPP3_1_OR_GREATER +#if NET6_0_OR_GREATER streamMock.Setup(x => x.ReadAsync(It.IsAny>(), It.IsAny())).Throws(); #else streamMock.Setup(x => x.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Throws(); @@ -154,11 +222,39 @@ public async Task Reader_ReadingTakesTooLong_TimesOut() Content = new StreamContent(streamMock.Object) }; - httpResponse.Content.Headers.Add("Content-type", "text/plain"); + httpResponse.Content.Headers.Add("Content-type", TextPlain); + + var responseBody = await httpResponseBodyReader.ReadAsync(httpResponse, CancellationToken.None); - var requestBody = await httpResponseBodyReader.ReadAsync(httpResponse, CancellationToken.None); + responseBody.Should().Be(Constants.ReadCancelledByTimeout); + } + + [Fact] + public async Task Reader_ReadingTakesTooLongAndOperationCancelled_Throws() + { + var options = new LoggingOptions + { + ResponseBodyContentTypes = new HashSet { TextPlain }, + BodyReadTimeout = TimeSpan.Zero + }; + var httpResponseBodyReader = new HttpResponseBodyReader(options); + var streamMock = new Mock(); + var token = new CancellationToken(true); + var exception = new OperationCanceledException(token); +#if NET6_0_OR_GREATER + streamMock.Setup(x => x.ReadAsync(It.IsAny>(), It.IsAny())).Throws(exception); +#else + streamMock.Setup(x => x.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Throws(exception); +#endif + using var httpResponse = new HttpResponseMessage + { + Content = new StreamContent(streamMock.Object) + }; + httpResponse.Content.Headers.Add("Content-type", TextPlain); + + var act = async () => await httpResponseBodyReader.ReadAsync(httpResponse, token); - requestBody.Should().Be(Constants.ReadCancelled); + await act.Should().ThrowAsync().Where(e => e.CancellationToken.IsCancellationRequested); } [Fact] From 2977765f234a36ba1aa73c9a9219339e9bab2532 Mon Sep 17 00:00:00 2001 From: Nathanael Marchand Date: Mon, 18 Nov 2024 12:21:12 +0100 Subject: [PATCH 139/190] Make ActivityBaggageLogScopeWrapper implements IEnumerable> (#5589) Make ActivityBaggageLogScopeWrapper implement IEnumerable> --- .../Import/LoggerFactoryScopeProvider.cs | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs index 3e18106972d..7b13b12c7df 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs @@ -236,7 +236,7 @@ IEnumerator IEnumerable.GetEnumerator() } } - private sealed class ActivityBaggageLogScopeWrapper : IEnumerable> + private sealed class ActivityBaggageLogScopeWrapper : IEnumerable> { private readonly IEnumerable> _items; @@ -247,15 +247,10 @@ public ActivityBaggageLogScopeWrapper(IEnumerable> _items = items; } - public IEnumerator> GetEnumerator() - { - return _items.GetEnumerator(); - } + public IEnumerator> GetEnumerator() => + new BaggageEnumerator(_items.GetEnumerator()); - IEnumerator IEnumerable.GetEnumerator() - { - return _items.GetEnumerator(); - } + IEnumerator IEnumerable.GetEnumerator() => new BaggageEnumerator(_items.GetEnumerator()); public override string ToString() { @@ -285,6 +280,27 @@ public override string ToString() return result; } } + + private readonly struct BaggageEnumerator : IEnumerator> + { + private readonly IEnumerator> _enumerator; + + public BaggageEnumerator(IEnumerator> enumerator) + { + _enumerator = enumerator; + } + + public KeyValuePair Current => + new KeyValuePair(_enumerator.Current.Key, _enumerator.Current.Value); + + object? IEnumerator.Current => Current; + + public void Dispose() => _enumerator.Dispose(); + + public bool MoveNext() => _enumerator.MoveNext(); + + public void Reset() => _enumerator.Reset(); + } } } From 475f317bc558b1b0da859efde26848d720ea5a47 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 18 Nov 2024 07:44:10 -0500 Subject: [PATCH 140/190] Add a [DebuggerDisplay] to GeneratedEmbeddings (#5657) --- .../Embeddings/GeneratedEmbeddings.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/GeneratedEmbeddings.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/GeneratedEmbeddings.cs index e983dd3b64b..23470a8875f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/GeneratedEmbeddings.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/GeneratedEmbeddings.cs @@ -3,12 +3,14 @@ using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; /// Represents the result of an operation to generate embeddings. /// Specifies the type of the generated embeddings. +[DebuggerDisplay("Count = {Count}")] public sealed class GeneratedEmbeddings : IList, IReadOnlyList where TEmbedding : Embedding { From e0f354a3869a77f409d57ee26021a7ba395e21d9 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 18 Nov 2024 07:44:27 -0500 Subject: [PATCH 141/190] Annotate private DebuggerDisplay props as DebuggerBrowsableState.Never (#5656) It's just noise. --- .../ChatCompletion/ChatResponseFormatJson.cs | 1 + .../ChatCompletion/RequiredChatToolMode.cs | 1 + .../Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs | 1 + .../Contents/FunctionCallContent.cs | 1 + .../Contents/FunctionResultContent.cs | 1 + .../Contents/UsageContent.cs | 1 + .../Microsoft.Extensions.AI.Abstractions/UsageDetails.cs | 1 + 7 files changed, 7 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs index e26c769ca62..23b6ff635a8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs @@ -55,5 +55,6 @@ public override int GetHashCode() => typeof(ChatResponseFormatJson).GetHashCode(); /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => Schema ?? "JSON"; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs index ef410ba24db..74858dfe89b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs @@ -47,6 +47,7 @@ public RequiredChatToolMode(string? requiredFunctionName) // Equals/GetHashCode as well, which they likely won't. /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => $"Required: {RequiredFunctionName ?? "Any"}"; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs index e677bdcf36b..39d610a6dcb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -196,6 +196,7 @@ public ReadOnlyMemory? Data } /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay { get diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index ea3458fb5b6..b42c41e7cc8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -97,6 +97,7 @@ public static FunctionCallContent CreateFromParsedArguments( } /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay { get diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs index b05553f16b8..2c9778e1d03 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs @@ -69,6 +69,7 @@ public FunctionResultContent(string callId, string name, object? result) public Exception? Exception { get; set; } /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay { get diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UsageContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UsageContent.cs index 22d86bd97cb..16e9d08b324 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UsageContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UsageContent.cs @@ -38,5 +38,6 @@ public UsageDetails Details } /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => _details.DebuggerDisplay; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs index f12ed819a6e..1e836da5045 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs @@ -23,6 +23,7 @@ public class UsageDetails public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] internal string DebuggerDisplay { get From f085689eb26bd15a5e37a1960cd30e8e65f0ddcc Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 18 Nov 2024 07:48:19 -0500 Subject: [PATCH 142/190] Fix M.E.AI argument tests to validate argument names (#5653) --- .../Embeddings/EmbeddingGenerationOptions.cs | 2 +- .../ChatCompletion/ChatFinishReasonTests.cs | 4 ++-- .../ChatCompletion/ChatResponseFormatTests.cs | 6 +++--- .../ChatCompletion/ChatRoleTests.cs | 4 ++-- .../ChatCompletion/DelegatingChatClientTests.cs | 2 +- .../Contents/DataContentTests{T}.cs | 6 +++--- .../Contents/FunctionCallContentTests..cs | 8 ++++---- .../Embeddings/DelegatingEmbeddingGeneratorTests.cs | 2 +- .../Embeddings/EmbeddingGenerationOptionsTests.cs | 4 ++-- .../Embeddings/GeneratedEmbeddingsTests.cs | 12 ++++++------ .../ChatCompletion/ChatClientBuilderTest.cs | 4 ++-- .../Embeddings/EmbeddingGeneratorBuilderTests.cs | 5 +++-- .../Functions/AIFunctionFactoryTest.cs | 10 +++++----- 13 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs index 27b84273b5b..4343983c550 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs @@ -18,7 +18,7 @@ public int? Dimensions { if (value is not null) { - _ = Throw.IfLessThan(value.Value, 1); + _ = Throw.IfLessThan(value.Value, 1, nameof(value)); } _dimensions = value; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatFinishReasonTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatFinishReasonTests.cs index 0318a77b47b..afe253bebac 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatFinishReasonTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatFinishReasonTests.cs @@ -18,8 +18,8 @@ public void Constructor_Value_Roundtrips() [Fact] public void Constructor_NullOrWhiteSpace_Throws() { - Assert.Throws(() => new ChatFinishReason(null!)); - Assert.Throws(() => new ChatFinishReason(" ")); + Assert.Throws("value", () => new ChatFinishReason(null!)); + Assert.Throws("value", () => new ChatFinishReason(" ")); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs index f4a63f34e05..22c7a99bdaf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs @@ -19,9 +19,9 @@ public void Singletons_Idempotent() [Fact] public void Constructor_InvalidArgs_Throws() { - Assert.Throws(() => new ChatResponseFormatJson(null, "name")); - Assert.Throws(() => new ChatResponseFormatJson(null, null, "description")); - Assert.Throws(() => new ChatResponseFormatJson(null, "name", "description")); + Assert.Throws("schemaName", () => new ChatResponseFormatJson(null, "name")); + Assert.Throws("schemaDescription", () => new ChatResponseFormatJson(null, null, "description")); + Assert.Throws("schemaName", () => new ChatResponseFormatJson(null, "name", "description")); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatRoleTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatRoleTests.cs index 7761aa2fdc3..e3a99c730fc 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatRoleTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatRoleTests.cs @@ -18,8 +18,8 @@ public void Constructor_Value_Roundtrips() [Fact] public void Constructor_NullOrWhiteSpace_Throws() { - Assert.Throws(() => new ChatRole(null!)); - Assert.Throws(() => new ChatRole(" ")); + Assert.Throws("value", () => new ChatRole(null!)); + Assert.Throws("value", () => new ChatRole(" ")); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs index 35027bb71f9..8245452210c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/DelegatingChatClientTests.cs @@ -14,7 +14,7 @@ public class DelegatingChatClientTests [Fact] public void RequiresInnerChatClient() { - Assert.Throws(() => new NoOpDelegatingChatClient(null!)); + Assert.Throws("innerClient", () => new NoOpDelegatingChatClient(null!)); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs index b34f6da0255..68934ccdba5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests{T}.cs @@ -68,9 +68,9 @@ public void Ctor_InvalidUri_Throws(string path, Type exception) [InlineData("type/subtype;key=")] [InlineData("type/subtype;=value")] [InlineData("type/subtype;key=value;another=")] - public void Ctor_InvalidMediaType_Throws(string mediaType) + public void Ctor_InvalidMediaType_Throws(string type) { - Assert.Throws(() => CreateDataContent("http://localhost/test", mediaType)); + Assert.Throws("mediaType", () => CreateDataContent("http://localhost/test", type)); } [Theory] @@ -151,7 +151,7 @@ public void Serialize_MatchesExpectedJson() [InlineData("""{ "mediaType":"text/plain" }""")] public void Deserialize_MissingUriString_Throws(string json) { - Assert.Throws(() => JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options)!); + Assert.Throws("uri", () => JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options)!); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs index 49ff719f8b5..82b1a518aca 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs @@ -331,9 +331,9 @@ public static void CreateFromParsedArguments_ParseException_HasExpectedHandling( [Fact] public static void CreateFromParsedArguments_NullInput_ThrowsArgumentNullException() { - Assert.Throws(() => FunctionCallContent.CreateFromParsedArguments((string)null!, "callId", "functionName", _ => null)); - Assert.Throws(() => FunctionCallContent.CreateFromParsedArguments("{}", null!, "functionName", _ => null)); - Assert.Throws(() => FunctionCallContent.CreateFromParsedArguments("{}", "callId", null!, _ => null)); - Assert.Throws(() => FunctionCallContent.CreateFromParsedArguments("{}", "callId", "functionName", null!)); + Assert.Throws("encodedArguments", () => FunctionCallContent.CreateFromParsedArguments((string)null!, "callId", "functionName", _ => null)); + Assert.Throws("callId", () => FunctionCallContent.CreateFromParsedArguments("{}", null!, "functionName", _ => null)); + Assert.Throws("name", () => FunctionCallContent.CreateFromParsedArguments("{}", "callId", null!, _ => null)); + Assert.Throws("argumentParser", () => FunctionCallContent.CreateFromParsedArguments("{}", "callId", "functionName", null!)); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs index 3f6732a410d..7ba6de333e0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs @@ -14,7 +14,7 @@ public class DelegatingEmbeddingGeneratorTests [Fact] public void RequiresInnerService() { - Assert.Throws(() => new NoOpDelegatingEmbeddingGenerator(null!)); + Assert.Throws("innerGenerator", () => new NoOpDelegatingEmbeddingGenerator(null!)); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs index fbc8b390abf..97ffecfc1f6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs @@ -27,8 +27,8 @@ public void Constructor_Parameterless_PropsDefaulted() public void InvalidArgs_Throws() { EmbeddingGenerationOptions options = new(); - Assert.Throws(() => options.Dimensions = 0); - Assert.Throws(() => options.Dimensions = -1); + Assert.Throws("value", () => options.Dimensions = 0); + Assert.Throws("value", () => options.Dimensions = -1); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs index 4ebd9465ca8..b7dffb1c46c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs @@ -46,8 +46,8 @@ public void Ctor_ValidArgs_NoExceptions() instance.CopyTo(Array.Empty>(), 0); - Assert.Throws(() => instance[0]); - Assert.Throws(() => instance[-1]); + Assert.Throws("index", () => instance[0]); + Assert.Throws("index", () => instance[-1]); } } @@ -77,8 +77,8 @@ public void Ctor_RoundtripsEnumerable() Assert.False(generatedEmbeddings.Contains(null!)); Assert.Equal(-1, generatedEmbeddings.IndexOf(null!)); - Assert.Throws(() => generatedEmbeddings[-1]); - Assert.Throws(() => generatedEmbeddings[2]); + Assert.Throws("index", () => generatedEmbeddings[-1]); + Assert.Throws("index", () => generatedEmbeddings[2]); Assert.True(embeddings.SequenceEqual(generatedEmbeddings)); @@ -240,7 +240,7 @@ public void Indexer_InvalidIndex_Throws() embeddings.AddRange(new[] { e1, e2 }); Assert.Equal(2, embeddings.Count); - Assert.Throws(() => embeddings[-1]); - Assert.Throws(() => embeddings[2]); + Assert.Throws("index", () => embeddings[-1]); + Assert.Throws("index", () => embeddings[2]); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs index 8630cfe1702..b53bcf5f16e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs @@ -58,13 +58,13 @@ public void BuildsPipelineInOrderAdded() [Fact] public void DoesNotAcceptNullInnerService() { - Assert.Throws(() => new ChatClientBuilder((IChatClient)null!)); + Assert.Throws("innerClient", () => new ChatClientBuilder((IChatClient)null!)); } [Fact] public void DoesNotAcceptNullFactories() { - Assert.Throws(() => new ChatClientBuilder((Func)null!)); + Assert.Throws("innerClientFactory", () => new ChatClientBuilder((Func)null!)); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs index b25044992e8..fe7f36a48ac 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs @@ -57,13 +57,14 @@ public void BuildsPipelineInOrderAdded() [Fact] public void DoesNotAcceptNullInnerService() { - Assert.Throws(() => new EmbeddingGeneratorBuilder>((IEmbeddingGenerator>)null!)); + Assert.Throws("innerGenerator", () => new EmbeddingGeneratorBuilder>((IEmbeddingGenerator>)null!)); } [Fact] public void DoesNotAcceptNullFactories() { - Assert.Throws(() => new EmbeddingGeneratorBuilder>((Func>>)null!)); + Assert.Throws("innerGeneratorFactory", + () => new EmbeddingGeneratorBuilder>((Func>>)null!)); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 0bec845babc..c72a2f3082f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -15,11 +15,11 @@ public class AIFunctionFactoryTest [Fact] public void InvalidArguments_Throw() { - Assert.Throws(() => AIFunctionFactory.Create(method: null!)); - Assert.Throws(() => AIFunctionFactory.Create(method: null!, target: new object())); - Assert.Throws(() => AIFunctionFactory.Create(method: null!, target: new object(), name: "myAiFunk")); - Assert.Throws(() => AIFunctionFactory.Create(typeof(AIFunctionFactoryTest).GetMethod(nameof(InvalidArguments_Throw))!, null)); - Assert.Throws(() => AIFunctionFactory.Create(typeof(List<>).GetMethod("Add")!, new List())); + Assert.Throws("method", () => AIFunctionFactory.Create(method: null!)); + Assert.Throws("method", () => AIFunctionFactory.Create(method: null!, target: new object())); + Assert.Throws("method", () => AIFunctionFactory.Create(method: null!, target: new object(), name: "myAiFunk")); + Assert.Throws("target", () => AIFunctionFactory.Create(typeof(AIFunctionFactoryTest).GetMethod(nameof(InvalidArguments_Throw))!, null)); + Assert.Throws("method", () => AIFunctionFactory.Create(typeof(List<>).GetMethod("Add")!, new List())); } [Fact] From 06edb3cf05cf02573772b9d475afb8b092ae67e9 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 18 Nov 2024 09:20:47 -0500 Subject: [PATCH 143/190] Remove duplicate GetCacheKey methods (#5651) * Remove duplicate GetCacheKey methods Consolidate to only the `ReadOnlySpan`-based method. * Update XML comments to say that the values are serialized --- .../ChatCompletion/CachingChatClient.cs | 25 +++++++++++-------- .../DistributedCachingChatClient.cs | 16 +++--------- .../Embeddings/CachingEmbeddingGenerator.cs | 11 +++----- .../DistributedCachingEmbeddingGenerator.cs | 10 +++----- .../DistributedCachingChatClientTest.cs | 14 ++++++++--- ...istributedCachingEmbeddingGeneratorTest.cs | 15 +++++++++-- 6 files changed, 48 insertions(+), 43 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs index 770ffa60cfc..f2de7f92fc8 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.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; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; @@ -16,6 +17,12 @@ namespace Microsoft.Extensions.AI; /// public abstract class CachingChatClient : DelegatingChatClient { + /// A boxed value. + private static readonly object _boxedTrue = true; + + /// A boxed value. + private static readonly object _boxedFalse = false; + /// Initializes a new instance of the class. /// The underlying . protected CachingChatClient(IChatClient innerClient) @@ -45,7 +52,7 @@ public override async Task CompleteAsync(IList chat // We're only storing the final result, not the in-flight task, so that we can avoid caching failures // or having problems when one of the callers cancels but others don't. This has the drawback that // concurrent callers might trigger duplicate requests, but that's acceptable. - var cacheKey = GetCacheKey(false, chatMessages, options); + var cacheKey = GetCacheKey(_boxedFalse, chatMessages, options); if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is not { } result) { @@ -68,7 +75,7 @@ public override async IAsyncEnumerable CompleteSt // we make a streaming request, yielding those results, but then convert those into a non-streaming // result and cache it. When we get a cache hit, we yield the non-streaming result as a streaming one. - var cacheKey = GetCacheKey(true, chatMessages, options); + var cacheKey = GetCacheKey(_boxedTrue, chatMessages, options); if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is { } chatCompletion) { // Yield all of the cached items. @@ -93,7 +100,7 @@ public override async IAsyncEnumerable CompleteSt } else { - var cacheKey = GetCacheKey(true, chatMessages, options); + var cacheKey = GetCacheKey(_boxedTrue, chatMessages, options); if (await ReadCacheStreamingAsync(cacheKey, cancellationToken).ConfigureAwait(false) is { } existingChunks) { // Yield all of the cached items. @@ -118,14 +125,10 @@ public override async IAsyncEnumerable CompleteSt } } - /// - /// Computes a cache key for the specified call parameters. - /// - /// A flag to indicate if this is a streaming call. - /// The chat content. - /// The chat options to configure the request. - /// A string that will be used as a cache key. - protected abstract string GetCacheKey(bool streaming, IList chatMessages, ChatOptions? options); + /// Computes a cache key for the specified values. + /// The values to inform the key. + /// The computed key. + protected abstract string GetCacheKey(params ReadOnlySpan values); /// /// Returns a previously cached , if available. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs index 678e9bd6523..a5bee20fa48 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs @@ -20,12 +20,6 @@ namespace Microsoft.Extensions.AI; /// public class DistributedCachingChatClient : CachingChatClient { - /// A boxed value. - private static readonly object _boxedTrue = true; - - /// A boxed value. - private static readonly object _boxedFalse = false; - /// The instance that will be used as the backing store for the cache. private readonly IDistributedCache _storage; @@ -98,15 +92,11 @@ protected override async Task WriteCacheStreamingAsync(string key, IReadOnlyList await _storage.SetAsync(key, newJson, cancellationToken).ConfigureAwait(false); } - /// - protected override string GetCacheKey(bool streaming, IList chatMessages, ChatOptions? options) => - GetCacheKey([streaming ? _boxedTrue : _boxedFalse, chatMessages, options]); - - /// Gets a cache key based on the supplied values. + /// Computes a cache key for the specified values. /// The values to inform the key. /// The computed key. - /// This provides the default implementation for . - protected string GetCacheKey(ReadOnlySpan values) + /// The are serialized to JSON using in order to compute the key. + protected override string GetCacheKey(params ReadOnlySpan values) { _jsonSerializerOptions.MakeReadOnly(); return CachingHelpers.GetCacheKey(values, _jsonSerializerOptions); diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs index d632431102c..688e4b2353d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs @@ -106,13 +106,10 @@ public override async Task> GenerateAsync( return results; } - /// - /// Computes a cache key for the specified call parameters. - /// - /// The for which an embedding is being requested. - /// The options to configure the request. - /// A string that will be used as a cache key. - protected abstract string GetCacheKey(TInput value, EmbeddingGenerationOptions? options); + /// Computes a cache key for the specified values. + /// The values to inform the key. + /// The computed key. + protected abstract string GetCacheKey(params ReadOnlySpan values); /// Returns a previously cached , if available. /// The cache key. diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs index 6482ed8ed2b..32abb78e18b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs @@ -74,15 +74,11 @@ protected override async Task WriteCacheAsync(string key, TEmbedding value, Canc await _storage.SetAsync(key, newJson, cancellationToken).ConfigureAwait(false); } - /// - protected override string GetCacheKey(TInput value, EmbeddingGenerationOptions? options) => - GetCacheKey([value, options]); - - /// Gets a cache key based on the supplied values. + /// Computes a cache key for the specified values. /// The values to inform the key. /// The computed key. - /// This provides the default implementation for . - protected string GetCacheKey(ReadOnlySpan values) + /// The are serialized to JSON using in order to compute the key. + protected override string GetCacheKey(params ReadOnlySpan values) { _jsonSerializerOptions.MakeReadOnly(); return CachingHelpers.GetCacheKey(values, _jsonSerializerOptions); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs index dcc6068b3ce..7ace4f2d294 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs @@ -815,10 +815,18 @@ private static async Task AssertCompletionsEqualAsync(IReadOnlyList chatMessages, ChatOptions? options) + protected override string GetCacheKey(params ReadOnlySpan values) { - var baseKey = base.GetCacheKey(streaming, chatMessages, options); - return baseKey + options?.AdditionalProperties?["someKey"]?.ToString(); + var baseKey = base.GetCacheKey(values); + foreach (var value in values) + { + if (value is ChatOptions options) + { + return baseKey + options.AdditionalProperties?["someKey"]?.ToString(); + } + } + + return baseKey; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs index 55cc206ebfc..d32a249c7dc 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs @@ -350,7 +350,18 @@ private static void AssertEmbeddingsEqual(Embedding expected, Embedding> innerGenerator, IDistributedCache storage) : DistributedCachingEmbeddingGenerator>(innerGenerator, storage) { - protected override string GetCacheKey(string value, EmbeddingGenerationOptions? options) => - base.GetCacheKey(value, options) + options?.AdditionalProperties?["someKey"]?.ToString(); + protected override string GetCacheKey(params ReadOnlySpan values) + { + var baseKey = base.GetCacheKey(values); + foreach (var value in values) + { + if (value is EmbeddingGenerationOptions options) + { + return baseKey + options.AdditionalProperties?["someKey"]?.ToString(); + } + } + + return baseKey; + } } } From b29e149b85c1ae628f4960d47867320a24934809 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 18 Nov 2024 09:37:32 -0500 Subject: [PATCH 144/190] Augment XML comments for AIFunctionFactory.Create (#5658) * Augment XML comments for AIFunctionFactory.Create * Add JSON serialization comments --- .../Functions/AIFunctionFactory.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 09d55388f75..854ec2f162a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -28,6 +28,22 @@ public static partial class AIFunctionFactory /// The method to be represented via the created . /// Metadata to use to override defaults inferred from . /// The created for invoking . + /// + /// + /// The resulting exposes metadata about the function via . + /// This metadata includes the function's name, description, and parameters. All of that information may be specified + /// explicitly via ; however, if not specified, defaults are inferred by examining + /// . That includes examining the method and its parameters for s. + /// + /// + /// Return values are serialized to using 's + /// . Arguments that are not already of the expected type are + /// marshaled to the expected type via JSON and using 's + /// . If the argument is a , + /// , or , it is deserialized directly. If the argument is anything else unknown, + /// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. + /// + /// public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions? options) { _ = Throw.IfNull(method); @@ -41,6 +57,22 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryCreateOptions? /// The description to use for the . /// The used to marshal function parameters and any return value. /// The created for invoking . + /// + /// + /// The resulting exposes metadata about the function via . + /// This metadata includes the function's name, description, and parameters. The function's name and description may + /// be specified explicitly via and , but if they're not, this method + /// will infer values from examining . That includes looking for + /// attributes on the method itself and on its parameters. + /// + /// + /// Return values are serialized to using . + /// Arguments that are not already of the expected type are marshaled to the expected type via JSON and using + /// . If the argument is a , , + /// or , it is deserialized directly. If the argument is anything else unknown, it is + /// round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. + /// + /// public static AIFunction Create(Delegate method, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null) { _ = Throw.IfNull(method); @@ -68,6 +100,22 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// /// Metadata to use to override defaults inferred from . /// The created for invoking . + /// + /// + /// The resulting exposes metadata about the function via . + /// This metadata includes the function's name, description, and parameters. All of that information may be specified + /// explicitly via ; however, if not specified, defaults are inferred by examining + /// . That includes examining the method and its parameters for s. + /// + /// + /// Return values are serialized to using 's + /// . Arguments that are not already of the expected type are + /// marshaled to the expected type via JSON and using 's + /// . If the argument is a , + /// , or , it is deserialized directly. If the argument is anything else unknown, + /// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. + /// + /// public static AIFunction Create(MethodInfo method, object? target, AIFunctionFactoryCreateOptions? options) { _ = Throw.IfNull(method); @@ -87,6 +135,22 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// The description to use for the . /// The used to marshal function parameters and return value. /// The created for invoking . + /// + /// + /// The resulting exposes metadata about the function via . + /// This metadata includes the function's name, description, and parameters. The function's name and description may + /// be specified explicitly via and , but if they're not, this method + /// will infer values from examining . That includes looking for + /// attributes on the method itself and on its parameters. + /// + /// + /// Return values are serialized to using . + /// Arguments that are not already of the expected type are marshaled to the expected type via JSON and using + /// . If the argument is a , , + /// or , it is deserialized directly. If the argument is anything else unknown, it is + /// round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. + /// + /// public static AIFunction Create(MethodInfo method, object? target, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null) { _ = Throw.IfNull(method); From c4689473f56ce5baaa6f77f4f89de7d3daa489b9 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:45:48 +0000 Subject: [PATCH 145/190] Update dependencies from https://github.com/dotnet/arcade build 20241112.13 (#5662) [main] Update dependencies from dotnet/arcade --- eng/Version.Details.xml | 8 ++++---- global.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 6c3afa6d940..93f55e662b3 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -186,13 +186,13 @@ - + https://github.com/dotnet/arcade - 3c393bbd85ae16ddddba20d0b75035b0c6f1a52d + 1c7e09a8d9c9c9b15ba574cd6a496553505559de - + https://github.com/dotnet/arcade - 3c393bbd85ae16ddddba20d0b75035b0c6f1a52d + 1c7e09a8d9c9c9b15ba574cd6a496553505559de diff --git a/global.json b/global.json index 8cb95c3b459..3778d7cce2c 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.24516.2", - "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.24516.2" + "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.24562.13", + "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.24562.13" } } From 930af05f2b1e8fc9c5ea34736d4365e0c1a51b8d Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 18 Nov 2024 10:12:30 -0500 Subject: [PATCH 146/190] Add AsBuilder extensions for IChatClient and IEmbeddingGenerator (#5652) * Add ToBuilder extensions for IChatClient and IEmbeddingGenerator Enables a fluent style of construction of a pipeline from a client/generator, and not having to specify the generic type parameters for the embedding generator builder. * Rename ToBuilder to AsBuilder --- .../ChatClientBuilderChatClientExtensions.cs | 25 ++++++++++++++ ...atorBuilderEmbeddingGeneratorExtensions.cs | 33 +++++++++++++++++++ .../AzureAIInferenceChatClientTests.cs | 3 +- ...AzureAIInferenceEmbeddingGeneratorTests.cs | 3 +- .../ChatClientIntegrationTests.cs | 11 ++++--- .../EmbeddingGeneratorIntegrationTests.cs | 6 ++-- .../ReducingChatClientTests.cs | 3 +- .../OllamaChatClientIntegrationTests.cs | 6 ++-- .../OllamaChatClientTests.cs | 3 +- .../OllamaEmbeddingGeneratorTests.cs | 3 +- .../OpenAIChatClientTests.cs | 6 ++-- .../OpenAIEmbeddingGeneratorTests.cs | 6 ++-- .../ChatCompletion/ChatClientBuilderTest.cs | 1 + .../ConfigureOptionsChatClientTests.cs | 5 +-- .../DistributedCachingChatClientTest.cs | 3 +- .../FunctionInvokingChatClientTests.cs | 4 +-- .../ChatCompletion/LoggingChatClientTests.cs | 6 ++-- .../OpenTelemetryChatClientTests.cs | 3 +- ...ConfigureOptionsEmbeddingGeneratorTests.cs | 5 +-- ...istributedCachingEmbeddingGeneratorTest.cs | 3 +- .../EmbeddingGeneratorBuilderTests.cs | 5 +-- .../LoggingEmbeddingGeneratorTests.cs | 3 +- 22 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderChatClientExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderEmbeddingGeneratorExtensions.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderChatClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderChatClientExtensions.cs new file mode 100644 index 00000000000..87983bf2367 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilderChatClientExtensions.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for working with in the context of . +public static class ChatClientBuilderChatClientExtensions +{ + /// Creates a new using as its inner client. + /// The client to use as the inner client. + /// The new instance. + /// + /// This method is equivalent to using the constructor directly, + /// specifying as the inner client. + /// + public static ChatClientBuilder AsBuilder(this IChatClient innerClient) + { + _ = Throw.IfNull(innerClient); + + return new ChatClientBuilder(innerClient); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderEmbeddingGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderEmbeddingGeneratorExtensions.cs new file mode 100644 index 00000000000..73784f56916 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilderEmbeddingGeneratorExtensions.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 Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for working with +/// in the context of . +public static class EmbeddingGeneratorBuilderEmbeddingGeneratorExtensions +{ + /// + /// Creates a new using + /// as its inner generator. + /// + /// The type from which embeddings will be generated. + /// The type of embeddings to generate. + /// The generator to use as the inner generator. + /// The new instance. + /// + /// This method is equivalent to using the + /// constructor directly, specifying as the inner generator. + /// + public static EmbeddingGeneratorBuilder AsBuilder( + this IEmbeddingGenerator innerGenerator) + where TEmbedding : Embedding + { + _ = Throw.IfNull(innerGenerator); + + return new EmbeddingGeneratorBuilder(innerGenerator); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index c0f79efdd62..da2b1923749 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -77,7 +77,8 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.Same(client, chatClient.GetService()); - using IChatClient pipeline = new ChatClientBuilder(chatClient) + using IChatClient pipeline = chatClient + .AsBuilder() .UseFunctionInvocation() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs index 843766515b2..d28ea111157 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs @@ -63,7 +63,8 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.Same(embeddingGenerator, embeddingGenerator.GetService>>()); Assert.Same(client, embeddingGenerator.GetService()); - using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>(embeddingGenerator) + using IEmbeddingGenerator> pipeline = embeddingGenerator + .AsBuilder() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .Build(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 871769df33c..cf113c878f6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -377,7 +377,8 @@ public virtual async Task Caching_BeforeFunctionInvocation_AvoidsExtraCalls() }, "GetTemperature"); // First call executes the function and calls the LLM - using var chatClient = new ChatClientBuilder(CreateChatClient()!) + using var chatClient = CreateChatClient()! + .AsBuilder() .ConfigureOptions(options => options.Tools = [getTemperature]) .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .UseFunctionInvocation() @@ -415,7 +416,8 @@ public virtual async Task Caching_AfterFunctionInvocation_FunctionOutputUnchange }, "GetTemperature"); // First call executes the function and calls the LLM - using var chatClient = new ChatClientBuilder(CreateChatClient()!) + using var chatClient = CreateChatClient()! + .AsBuilder() .ConfigureOptions(options => options.Tools = [getTemperature]) .UseFunctionInvocation() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) @@ -454,7 +456,8 @@ public virtual async Task Caching_AfterFunctionInvocation_FunctionOutputChangedA }, "GetTemperature"); // First call executes the function and calls the LLM - using var chatClient = new ChatClientBuilder(CreateChatClient()!) + using var chatClient = CreateChatClient()! + .AsBuilder() .ConfigureOptions(options => options.Tools = [getTemperature]) .UseFunctionInvocation() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) @@ -573,7 +576,7 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() .AddInMemoryExporter(activities) .Build(); - var chatClient = new ChatClientBuilder(CreateChatClient()!) + var chatClient = CreateChatClient()!.AsBuilder() .UseOpenTelemetry(sourceName: sourceName) .Build(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs index 7ba3878385b..aacd07b561b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs @@ -81,7 +81,8 @@ public virtual async Task Caching_SameOutputsForSameInput() { SkipIfNotEnabled(); - using var generator = new EmbeddingGeneratorBuilder>(CreateEmbeddingGenerator()!) + using var generator = CreateEmbeddingGenerator()! + .AsBuilder() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .UseCallCounting() .Build(); @@ -110,7 +111,8 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() .AddInMemoryExporter(activities) .Build(); - var embeddingGenerator = new EmbeddingGeneratorBuilder>(CreateEmbeddingGenerator()!) + var embeddingGenerator = CreateEmbeddingGenerator()! + .AsBuilder() .UseOpenTelemetry(sourceName: sourceName) .Build(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs index 7e3783976dc..e9ed67a81f0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs @@ -37,7 +37,8 @@ public async Task Reduction_LimitsMessagesBasedOnTokenLimit() } }; - using var client = new ChatClientBuilder(innerClient) + using var client = innerClient + .AsBuilder() .UseChatReducer(new TokenCountingChatReducer(_gpt4oTokenizer, 40)) .Build(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs index 23d910f5e33..76a3f940595 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs @@ -37,7 +37,8 @@ public async Task PromptBasedFunctionCalling_NoArgs() { SkipIfNotEnabled(); - using var chatClient = new ChatClientBuilder(CreateChatClient()!) + using var chatClient = CreateChatClient()! + .AsBuilder() .UseFunctionInvocation() .UsePromptBasedFunctionCalling() .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) @@ -61,7 +62,8 @@ public async Task PromptBasedFunctionCalling_WithArgs() { SkipIfNotEnabled(); - using var chatClient = new ChatClientBuilder(CreateChatClient()!) + using var chatClient = CreateChatClient()! + .AsBuilder() .UseFunctionInvocation() .UsePromptBasedFunctionCalling() .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs index 4e01987a158..6c2fcebb154 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs @@ -48,7 +48,8 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.Same(client, client.GetService()); Assert.Same(client, client.GetService()); - using IChatClient pipeline = new ChatClientBuilder(client) + using IChatClient pipeline = client + .AsBuilder() .UseFunctionInvocation() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs index 6dd8b82d986..e044ef1d468 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs @@ -29,7 +29,8 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.Same(generator, generator.GetService()); Assert.Same(generator, generator.GetService>>()); - using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>(generator) + using IEmbeddingGenerator> pipeline = generator + .AsBuilder() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .Build(); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 41c118dc3cb..982df50a707 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -95,7 +95,8 @@ public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() Assert.NotNull(chatClient.GetService()); - using IChatClient pipeline = new ChatClientBuilder(chatClient) + using IChatClient pipeline = chatClient + .AsBuilder() .UseFunctionInvocation() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) @@ -119,7 +120,8 @@ public void GetService_ChatClient_SuccessfullyReturnsUnderlyingClient() Assert.Same(chatClient, chatClient.GetService()); Assert.Same(openAIClient, chatClient.GetService()); - using IChatClient pipeline = new ChatClientBuilder(chatClient) + using IChatClient pipeline = chatClient + .AsBuilder() .UseFunctionInvocation() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index 37a45f93441..4a8b7aad83a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -78,7 +78,8 @@ public void GetService_OpenAIClient_SuccessfullyReturnsUnderlyingClient() Assert.NotNull(embeddingGenerator.GetService()); - using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>(embeddingGenerator) + using IEmbeddingGenerator> pipeline = embeddingGenerator + .AsBuilder() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .Build(); @@ -100,7 +101,8 @@ public void GetService_EmbeddingClient_SuccessfullyReturnsUnderlyingClient() Assert.Same(embeddingGenerator, embeddingGenerator.GetService>>()); Assert.Same(openAIClient, embeddingGenerator.GetService()); - using IEmbeddingGenerator> pipeline = new EmbeddingGeneratorBuilder>(embeddingGenerator) + using IEmbeddingGenerator> pipeline = embeddingGenerator + .AsBuilder() .UseOpenTelemetry() .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) .Build(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs index b53bcf5f16e..1567545800e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs @@ -59,6 +59,7 @@ public void BuildsPipelineInOrderAdded() public void DoesNotAcceptNullInnerService() { Assert.Throws("innerClient", () => new ChatClientBuilder((IChatClient)null!)); + Assert.Throws("innerClient", () => ((IChatClient)null!).AsBuilder()); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs index 68a898dc743..8ceb16da329 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ConfigureOptionsChatClientTests.cs @@ -23,7 +23,7 @@ public void ConfigureOptionsChatClient_InvalidArgs_Throws() public void ConfigureOptions_InvalidArgs_Throws() { using var innerClient = new TestChatClient(); - var builder = new ChatClientBuilder(innerClient); + var builder = innerClient.AsBuilder(); Assert.Throws("configure", () => builder.ConfigureOptions(null!)); } @@ -55,7 +55,8 @@ public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullP }, }; - using var client = new ChatClientBuilder(innerClient) + using var client = innerClient + .AsBuilder() .ConfigureOptions(options => { Assert.NotSame(providedOptions, options); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs index 7ace4f2d294..d144c966f39 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs @@ -681,7 +681,8 @@ public async Task CanResolveIDistributedCacheFromDI() new(ChatRole.Assistant, [new TextContent("Hey")])])); } }; - using var outer = new ChatClientBuilder(testClient) + using var outer = testClient + .AsBuilder() .UseDistributedCache(configure: options => { options.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 1e4558901ca..d9df2fc89e3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -295,7 +295,7 @@ public async Task RejectsMultipleChoicesAsync() } }; - IChatClient service = new ChatClientBuilder(innerClient).UseFunctionInvocation().Build(); + IChatClient service = innerClient.AsBuilder().UseFunctionInvocation().Build(); List chat = [new ChatMessage(ChatRole.User, "hello")]; var ex = await Assert.ThrowsAsync( @@ -415,7 +415,7 @@ private static async Task> InvokeAndAssertAsync( } }; - IChatClient service = configurePipeline(new ChatClientBuilder(innerClient)).Build(); + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); var result = await service.CompleteAsync(chat, options, cts.Token); chat.Add(result.Message); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs index 38bc4e8f67d..e07364b42c3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs @@ -40,7 +40,8 @@ public async Task CompleteAsync_LogsStartAndCompletion(LogLevel level) }, }; - using IChatClient client = new ChatClientBuilder(innerClient) + using IChatClient client = innerClient + .AsBuilder() .UseLogging() .Build(services); @@ -86,7 +87,8 @@ static async IAsyncEnumerable GetUpdatesAsync() yield return new StreamingChatCompletionUpdate { Role = ChatRole.Assistant, Text = "whale" }; } - using IChatClient client = new ChatClientBuilder(innerClient) + using IChatClient client = innerClient + .AsBuilder() .UseLogging(logger) .Build(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs index 2080e2f02b2..3d7d05f981a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs @@ -86,7 +86,8 @@ async static IAsyncEnumerable CallbackAsync( }; } - var chatClient = new ChatClientBuilder(innerClient) + var chatClient = innerClient + .AsBuilder() .UseOpenTelemetry(loggerFactory, sourceName, configure: instance => { instance.EnableSensitiveData = enableSensitiveData; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs index ecb96c993ea..bec98684c91 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/ConfigureOptionsEmbeddingGeneratorTests.cs @@ -21,7 +21,7 @@ public void ConfigureOptionsEmbeddingGenerator_InvalidArgs_Throws() public void ConfigureOptions_InvalidArgs_Throws() { using var innerGenerator = new TestEmbeddingGenerator(); - var builder = new EmbeddingGeneratorBuilder>(innerGenerator); + var builder = innerGenerator.AsBuilder(); Assert.Throws("configure", () => builder.ConfigureOptions(null!)); } @@ -45,7 +45,8 @@ public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullP } }; - using var generator = new EmbeddingGeneratorBuilder>(innerGenerator) + using var generator = innerGenerator + .AsBuilder() .ConfigureOptions(options => { Assert.NotSame(providedOptions, options); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs index d32a249c7dc..04a7c574d53 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs @@ -321,7 +321,8 @@ public async Task CanResolveIDistributedCacheFromDI() return Task.FromResult>>([_expectedEmbedding]); }, }; - using var outer = new EmbeddingGeneratorBuilder>(testGenerator) + using var outer = testGenerator + .AsBuilder() .UseDistributedCache(configure: instance => { instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs index fe7f36a48ac..932df54b6d7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs @@ -36,7 +36,7 @@ public void BuildsPipelineInOrderAdded() { // Arrange using var expectedInnerGenerator = new TestEmbeddingGenerator(); - var builder = new EmbeddingGeneratorBuilder>(expectedInnerGenerator); + var builder = expectedInnerGenerator.AsBuilder(); builder.Use(next => new InnerServiceCapturingEmbeddingGenerator("First", next)); builder.Use(next => new InnerServiceCapturingEmbeddingGenerator("Second", next)); @@ -58,6 +58,7 @@ public void BuildsPipelineInOrderAdded() public void DoesNotAcceptNullInnerService() { Assert.Throws("innerGenerator", () => new EmbeddingGeneratorBuilder>((IEmbeddingGenerator>)null!)); + Assert.Throws("innerGenerator", () => ((IEmbeddingGenerator>)null!).AsBuilder()); } [Fact] @@ -71,7 +72,7 @@ public void DoesNotAcceptNullFactories() public void DoesNotAllowFactoriesToReturnNull() { using var innerGenerator = new TestEmbeddingGenerator(); - var builder = new EmbeddingGeneratorBuilder>(innerGenerator); + var builder = innerGenerator.AsBuilder(); builder.Use(_ => null!); var ex = Assert.Throws(() => builder.Build()); Assert.Contains("entry at index 0", ex.Message); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs index b8a342e5f73..ca5fa966ace 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs @@ -39,7 +39,8 @@ public async Task CompleteAsync_LogsStartAndCompletion(LogLevel level) }, }; - using IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>(innerGenerator) + using IEmbeddingGenerator> generator = innerGenerator + .AsBuilder() .UseLogging() .Build(services); From 5982f6abc369fee9654fdff92d1a939d20e87f60 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 18 Nov 2024 10:52:08 -0500 Subject: [PATCH 147/190] Reduce a bit of LINQ in M.E.AI (#5663) --- .../AdditionalPropertiesDictionary.cs | 3 +- .../ChatCompletion/ChatMessage.cs | 8 +-- .../StreamingChatCompletionUpdate.cs | 8 +-- .../Contents/AIContentExtensions.cs | 64 +++++++++++++++++++ .../ChatCompletion/OpenTelemetryChatClient.cs | 2 +- 5 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContentExtensions.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs index 8b8d69896bf..c780c1ccaf7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs @@ -111,7 +111,8 @@ public bool TryAdd(string key, object? value) public void Clear() => _dictionary.Clear(); /// - bool ICollection>.Contains(KeyValuePair item) => _dictionary.Contains(item); + bool ICollection>.Contains(KeyValuePair item) => + ((ICollection>)_dictionary).Contains(item); /// public bool ContainsKey(string key) => _dictionary.ContainsKey(key); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs index 6370319704b..d52cc36cdbb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; @@ -60,10 +59,10 @@ public string? AuthorName [JsonIgnore] public string? Text { - get => Contents.OfType().FirstOrDefault()?.Text; + get => Contents.FindFirst()?.Text; set { - if (Contents.OfType().FirstOrDefault() is { } textContent) + if (Contents.FindFirst() is { } textContent) { textContent.Text = value; } @@ -95,6 +94,5 @@ public IList Contents public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } /// - public override string ToString() => - string.Concat(Contents.OfType()); + public override string ToString() => Contents.ConcatText(); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs index 9978e0f29b7..36ae500e138 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdate.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI; @@ -66,10 +65,10 @@ public string? AuthorName [JsonIgnore] public string? Text { - get => Contents.OfType().FirstOrDefault()?.Text; + get => Contents.FindFirst()?.Text; set { - if (Contents.OfType().FirstOrDefault() is { } textContent) + if (Contents.FindFirst() is { } textContent) { textContent.Text = value; } @@ -116,6 +115,5 @@ public IList Contents public string? ModelId { get; set; } /// - public override string ToString() => - string.Concat(Contents.OfType()); + public override string ToString() => Contents.ConcatText(); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContentExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContentExtensions.cs new file mode 100644 index 00000000000..eb516e2a7c1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContentExtensions.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +#if !NET +using System.Linq; +#else +using System.Runtime.CompilerServices; +#endif + +namespace Microsoft.Extensions.AI; + +/// Internal extensions for working with . +internal static class AIContentExtensions +{ + /// Finds the first occurrence of a in the list. + public static T? FindFirst(this IList contents) + where T : AIContent + { + int count = contents.Count; + for (int i = 0; i < count; i++) + { + if (contents[i] is T t) + { + return t; + } + } + + return null; + } + + /// Concatenates the text of all instances in the list. + public static string ConcatText(this IList contents) + { + int count = contents.Count; + switch (count) + { + case 0: + break; + + case 1: + return contents[0] is TextContent tc ? tc.Text : string.Empty; + + default: +#if NET + DefaultInterpolatedStringHandler builder = new(0, 0, null, stackalloc char[512]); + for (int i = 0; i < count; i++) + { + if (contents[i] is TextContent text) + { + builder.AppendLiteral(text.Text); + } + } + + return builder.ToStringAndClear(); +#else + return string.Concat(contents.OfType()); +#endif + } + + return string.Empty; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index 7cf26e5944f..193006780a2 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -502,7 +502,7 @@ private AssistantEvent CreateAssistantEvent(ChatMessage message) { if (EnableSensitiveData) { - string content = string.Concat(message.Contents.OfType().Select(c => c.Text)); + string content = string.Concat(message.Contents.OfType()); if (content.Length > 0) { return content; From d551bb113acd4b067791b38b5490a8cfcd60f3c8 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 18 Nov 2024 11:42:37 -0500 Subject: [PATCH 148/190] Reverse order of services/inner in Use methods (#5664) --- .../ChatCompletion/ChatClientBuilder.cs | 8 ++++---- .../DistributedCachingChatClientBuilderExtensions.cs | 2 +- .../FunctionInvokingChatClientBuilderExtensions.cs | 2 +- .../ChatCompletion/LoggingChatClientBuilderExtensions.cs | 2 +- .../OpenTelemetryChatClientBuilderExtensions.cs | 2 +- ...stributedCachingEmbeddingGeneratorBuilderExtensions.cs | 2 +- .../Embeddings/EmbeddingGeneratorBuilder.cs | 8 ++++---- .../LoggingEmbeddingGeneratorBuilderExtensions.cs | 2 +- .../OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs | 2 +- .../ChatCompletion/ChatClientBuilderTest.cs | 2 +- .../ChatCompletion/DependencyInjectionPatterns.cs | 2 +- .../ChatCompletion/SingletonChatClientExtensions.cs | 4 ++-- .../Embeddings/EmbeddingGeneratorBuilderTests.cs | 2 +- 13 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs index dc902c8407a..e816101b4e1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs @@ -13,7 +13,7 @@ public sealed class ChatClientBuilder private readonly Func _innerClientFactory; /// The registered client factory instances. - private List>? _clientFactories; + private List>? _clientFactories; /// Initializes a new instance of the class. /// The inner that represents the underlying backend. @@ -46,7 +46,7 @@ public IChatClient Build(IServiceProvider? services = null) { for (var i = _clientFactories.Count - 1; i >= 0; i--) { - chatClient = _clientFactories[i](services, chatClient) ?? + chatClient = _clientFactories[i](chatClient, services) ?? throw new InvalidOperationException( $"The {nameof(ChatClientBuilder)} entry at index {i} returned null. " + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IChatClient)} instances."); @@ -63,13 +63,13 @@ public ChatClientBuilder Use(Func clientFactory) { _ = Throw.IfNull(clientFactory); - return Use((_, innerClient) => clientFactory(innerClient)); + return Use((innerClient, _) => clientFactory(innerClient)); } /// Adds a factory for an intermediate chat client to the chat client pipeline. /// The client factory function. /// The updated instance. - public ChatClientBuilder Use(Func clientFactory) + public ChatClientBuilder Use(Func clientFactory) { _ = Throw.IfNull(clientFactory); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClientBuilderExtensions.cs index d465161e1e4..6396459c09c 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClientBuilderExtensions.cs @@ -25,7 +25,7 @@ public static class DistributedCachingChatClientBuilderExtensions public static ChatClientBuilder UseDistributedCache(this ChatClientBuilder builder, IDistributedCache? storage = null, Action? configure = null) { _ = Throw.IfNull(builder); - return builder.Use((services, innerClient) => + return builder.Use((innerClient, services) => { storage ??= services.GetRequiredService(); var chatClient = new DistributedCachingChatClient(innerClient, storage); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.cs index fa64bcedc78..0d2d6f8bc9b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClientBuilderExtensions.cs @@ -28,7 +28,7 @@ public static ChatClientBuilder UseFunctionInvocation( { _ = Throw.IfNull(builder); - return builder.Use((services, innerClient) => + return builder.Use((innerClient, services) => { loggerFactory ??= services.GetService(); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs index 056ba5401fc..508617ba708 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs @@ -23,7 +23,7 @@ public static ChatClientBuilder UseLogging( { _ = Throw.IfNull(builder); - return builder.Use((services, innerClient) => + return builder.Use((innerClient, services) => { logger ??= services.GetRequiredService().CreateLogger(nameof(LoggingChatClient)); var chatClient = new LoggingChatClient(innerClient, logger); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs index 59c5c81a84d..28149a5fed2 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClientBuilderExtensions.cs @@ -28,7 +28,7 @@ public static ChatClientBuilder UseOpenTelemetry( ILoggerFactory? loggerFactory = null, string? sourceName = null, Action? configure = null) => - Throw.IfNull(builder).Use((services, innerClient) => + Throw.IfNull(builder).Use((innerClient, services) => { loggerFactory ??= services.GetService(); diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGeneratorBuilderExtensions.cs index 77aaa30e05d..7d42407d930 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGeneratorBuilderExtensions.cs @@ -32,7 +32,7 @@ public static EmbeddingGeneratorBuilder UseDistributedCache< where TEmbedding : Embedding { _ = Throw.IfNull(builder); - return builder.Use((services, innerGenerator) => + return builder.Use((innerGenerator, services) => { storage ??= services.GetRequiredService(); var result = new DistributedCachingEmbeddingGenerator(innerGenerator, storage); diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs index 7983ca495bf..0e1620f748d 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs @@ -16,7 +16,7 @@ public sealed class EmbeddingGeneratorBuilder private readonly Func> _innerGeneratorFactory; /// The registered client factory instances. - private List, IEmbeddingGenerator>>? _generatorFactories; + private List, IServiceProvider, IEmbeddingGenerator>>? _generatorFactories; /// Initializes a new instance of the class. /// The inner that represents the underlying backend. @@ -51,7 +51,7 @@ public IEmbeddingGenerator Build(IServiceProvider? services { for (var i = _generatorFactories.Count - 1; i >= 0; i--) { - embeddingGenerator = _generatorFactories[i](services, embeddingGenerator) ?? + embeddingGenerator = _generatorFactories[i](embeddingGenerator, services) ?? throw new InvalidOperationException( $"The {nameof(IEmbeddingGenerator)} entry at index {i} returned null. " + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IEmbeddingGenerator)} instances."); @@ -68,13 +68,13 @@ public EmbeddingGeneratorBuilder Use(Func generatorFactory(innerGenerator)); + return Use((innerGenerator, _) => generatorFactory(innerGenerator)); } /// Adds a factory for an intermediate embedding generator to the embedding generator pipeline. /// The generator factory function. /// The updated instance. - public EmbeddingGeneratorBuilder Use(Func, IEmbeddingGenerator> generatorFactory) + public EmbeddingGeneratorBuilder Use(Func, IServiceProvider, IEmbeddingGenerator> generatorFactory) { _ = Throw.IfNull(generatorFactory); diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs index 1335a3fd8d3..a83c1885ec6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs @@ -26,7 +26,7 @@ public static EmbeddingGeneratorBuilder UseLogging + return builder.Use((innerGenerator, services) => { logger ??= services.GetRequiredService().CreateLogger(nameof(LoggingEmbeddingGenerator)); var generator = new LoggingEmbeddingGenerator(innerGenerator, logger); diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs index 5f40f884bc4..3e9c36f6596 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGeneratorBuilderExtensions.cs @@ -31,7 +31,7 @@ public static EmbeddingGeneratorBuilder UseOpenTelemetry>? configure = null) where TEmbedding : Embedding => - Throw.IfNull(builder).Use((services, innerGenerator) => + Throw.IfNull(builder).Use((innerGenerator, services) => { loggerFactory ??= services.GetService(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs index 1567545800e..c9d09db9836 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs @@ -22,7 +22,7 @@ public void PassesServiceProviderToFactories() return expectedInnerClient; }); - builder.Use((serviceProvider, innerClient) => + builder.Use((innerClient, serviceProvider) => { Assert.Same(expectedServiceProvider, serviceProvider); Assert.Same(expectedInnerClient, innerClient); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs index 54c5011b103..c99d4511f75 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DependencyInjectionPatterns.cs @@ -109,7 +109,7 @@ public void CanRegisterKeyedSingletonUsingSharedInstance() Assert.IsType(instance.InnerClient); } - public class SingletonMiddleware(IServiceProvider services, IChatClient inner) : DelegatingChatClient(inner) + public class SingletonMiddleware(IChatClient inner, IServiceProvider services) : DelegatingChatClient(inner) { public new IChatClient InnerClient => base.InnerClient; public IServiceProvider Services => services; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/SingletonChatClientExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/SingletonChatClientExtensions.cs index e971a0ad322..5e636321b71 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/SingletonChatClientExtensions.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/SingletonChatClientExtensions.cs @@ -6,6 +6,6 @@ namespace Microsoft.Extensions.AI; public static class SingletonChatClientExtensions { public static ChatClientBuilder UseSingletonMiddleware(this ChatClientBuilder builder) - => builder.Use((services, inner) - => new DependencyInjectionPatterns.SingletonMiddleware(services, inner)); + => builder.Use((inner, services) + => new DependencyInjectionPatterns.SingletonMiddleware(inner, services)); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs index 932df54b6d7..bc4814d06d7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/EmbeddingGeneratorBuilderTests.cs @@ -22,7 +22,7 @@ public void PassesServiceProviderToFactories() return expectedInnerGenerator; }); - builder.Use((services, innerClient) => + builder.Use((innerClient, services) => { Assert.Same(expectedServiceProvider, services); return expectedOuterGenerator; From 8b9dc1d6888bcfbcc1b0bb46e8e62f2439e12293 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 18 Nov 2024 18:53:35 -0500 Subject: [PATCH 149/190] Add anonymous delegating clients / generators (#5650) --- eng/Versions.props | 2 + eng/packages/General-LTS.props | 1 + eng/packages/General-net9.props | 1 + .../README.md | 74 +++++ .../AnonymousDelegatingChatClient.cs | 213 +++++++++++++++ .../ChatCompletion/ChatClientBuilder.cs | 60 +++++ .../AnonymousDelegatingEmbeddingGenerator.cs | 44 +++ .../Embeddings/EmbeddingGeneratorBuilder.cs | 22 +- .../Microsoft.Extensions.AI.csproj | 1 + .../UseDelegateChatClientTests.cs | 255 ++++++++++++++++++ .../UseDelegateEmbeddingGeneratorTests.cs | 71 +++++ 11 files changed, 743 insertions(+), 1 deletion(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/Embeddings/AnonymousDelegatingEmbeddingGenerator.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/UseDelegateChatClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/UseDelegateEmbeddingGeneratorTests.cs diff --git a/eng/Versions.props b/eng/Versions.props index 7ad705c103a..bbf47bbf857 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -63,6 +63,7 @@ 9.0.0 9.0.0 9.0.0 + 9.0.0 9.0.1 9.0.1 @@ -112,6 +113,7 @@ 8.0.0 8.0.0 8.0.5 + 8.0.0 8.0.11 8.0.11 diff --git a/eng/packages/General-LTS.props b/eng/packages/General-LTS.props index b82ee443a77..4e24ca6630f 100644 --- a/eng/packages/General-LTS.props +++ b/eng/packages/General-LTS.props @@ -36,6 +36,7 @@ + diff --git a/eng/packages/General-net9.props b/eng/packages/General-net9.props index 8f7bae8b816..c477508b1b2 100644 --- a/eng/packages/General-net9.props +++ b/eng/packages/General-net9.props @@ -36,6 +36,7 @@ + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md index e13709cd932..338a34e0f1c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md @@ -329,6 +329,80 @@ var client = new RateLimitingChatClient( await client.CompleteAsync("What color is the sky?"); ``` +To make it easier to compose such components with others, the author of the component is recommended to create a "Use" extension method for registering this component into a pipeline, e.g. +```csharp +public static class RateLimitingChatClientExtensions +{ + public static ChatClientBuilder UseRateLimiting(this ChatClientBuilder builder, RateLimiter rateLimiter) => + builder.Use(innerClient => new RateLimitingChatClient(innerClient, rateLimiter)); +} +``` + +Such extensions may also query for relevant services from the DI container; the `IServiceProvider` used by the pipeline is passed in as an optional parameter: +```csharp +public static class RateLimitingChatClientExtensions +{ + public static ChatClientBuilder UseRateLimiting(this ChatClientBuilder builder, RateLimiter? rateLimiter = null) => + builder.Use((innerClient, services) => new RateLimitingChatClient(innerClient, services.GetRequiredService())); +} +``` + +The consumer can then easily use this in their pipeline, e.g. +```csharp +var client = new SampleChatClient(new Uri("http://localhost"), "test") + .AsBuilder() + .UseDistributedCache() + .UseRateLimiting() + .UseOpenTelemetry() + .Build(services); +``` + +The above extension methods demonstrate using a `Use` method on `ChatClientBuilder`. `ChatClientBuilder` also provides `Use` overloads that make it easier to +write such delegating handlers. For example, in the earlier `RateLimitingChatClient` example, the overrides of `CompleteAsync` and `CompleteStreamingAsync` only +need to do work before and after delegating to the next client in the pipeline. To achieve the same thing without writing a custom class, an overload of `Use` may +be used that accepts a delegate which is used for both `CompleteAsync` and `CompleteStreamingAsync`, reducing the boilderplate required: +```csharp +RateLimiter rateLimiter = ...; +var client = new SampleChatClient(new Uri("http://localhost"), "test") + .AsBuilder() + .UseDistributedCache() + .Use(async (chatMessages, options, nextAsync, cancellationToken) => + { + using var lease = await rateLimiter.AcquireAsync(permitCount: 1, cancellationToken).ConfigureAwait(false); + if (!lease.IsAcquired) + throw new InvalidOperationException("Unable to acquire lease."); + + await nextAsync(chatMessages, options, cancellationToken); + }) + .UseOpenTelemetry() + .Build(); +``` +This overload internally uses a public `AnonymousDelegatingChatClient`, which enables more complicated patterns with only a little additional code. +For example, to achieve the same as above but with the `RateLimiter` retrieved from DI: +```csharp +var client = new SampleChatClient(new Uri("http://localhost"), "test") + .AsBuilder() + .UseDistributedCache() + .Use((innerClient, services) => + { + RateLimiter rateLimiter = services.GetRequiredService(); + return new AnonymousDelegatingChatClient(innerClient, async (chatMessages, options, next, cancellationToken) => + { + using var lease = await rateLimiter.AcquireAsync(permitCount: 1, cancellationToken).ConfigureAwait(false); + if (!lease.IsAcquired) + throw new InvalidOperationException("Unable to acquire lease."); + + await next(chatMessages, options, cancellationToken); + }); + }) + .UseOpenTelemetry() + .Build(); +``` + +For scenarios where the developer would like to specify delegating implementations of `CompleteAsync` and `CompleteStreamingAsync` inline, +and where it's important to be able to write a different implementation for each in order to handle their unique return types specially, +another overload of `Use` exists that accepts a delegate for each. + #### Dependency Injection `IChatClient` implementations will typically be provided to an application via dependency injection (DI). In this example, an `IDistributedCache` is added into the DI container, as is an `IChatClient`. The registration for the `IChatClient` employs a builder that creates a pipeline containing a caching client (which will then use an `IDistributedCache` retrieved from DI) and the sample client. Elsewhere in the app, the injected `IChatClient` may be retrieved and used. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs new file mode 100644 index 00000000000..35dc69fd75c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs @@ -0,0 +1,213 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks + +namespace Microsoft.Extensions.AI; + +/// A delegating chat client that wraps an inner client with implementations provided by delegates. +public sealed class AnonymousDelegatingChatClient : DelegatingChatClient +{ + /// The delegate to use as the implementation of . + private readonly Func, ChatOptions?, IChatClient, CancellationToken, Task>? _completeFunc; + + /// The delegate to use as the implementation of . + /// + /// When non-, this delegate is used as the implementation of and + /// will be invoked with the same arguments as the method itself, along with a reference to the inner client. + /// When , will delegate directly to the inner client. + /// + private readonly Func, ChatOptions?, IChatClient, CancellationToken, IAsyncEnumerable>? _completeStreamingFunc; + + /// The delegate to use as the implementation of both and . + private readonly CompleteSharedFunc? _sharedFunc; + + /// + /// Initializes a new instance of the class. + /// + /// The inner client. + /// + /// A delegate that provides the implementation for both and . + /// In addition to the arguments for the operation, it's provided with a delegate to the inner client that should be + /// used to perform the operation on the inner client. It will handle both the non-streaming and streaming cases. + /// + /// + /// This overload may be used when the anonymous implementation needs to provide pre- and/or post-processing, but doesn't + /// need to interact with the results of the operation, which will come from the inner client. + /// + /// is . + /// is . + public AnonymousDelegatingChatClient(IChatClient innerClient, CompleteSharedFunc sharedFunc) + : base(innerClient) + { + _ = Throw.IfNull(sharedFunc); + + _sharedFunc = sharedFunc; + } + + /// + /// Initializes a new instance of the class. + /// + /// The inner client. + /// + /// A delegate that provides the implementation for . When , + /// must be non-null, and the implementation of + /// will use for the implementation. + /// + /// + /// A delegate that provides the implementation for . When , + /// must be non-null, and the implementation of + /// will use for the implementation. + /// + /// is . + /// Both and are . + public AnonymousDelegatingChatClient( + IChatClient innerClient, + Func, ChatOptions?, IChatClient, CancellationToken, Task>? completeFunc, + Func, ChatOptions?, IChatClient, CancellationToken, IAsyncEnumerable>? completeStreamingFunc) + : base(innerClient) + { + ThrowIfBothDelegatesNull(completeFunc, completeStreamingFunc); + + _completeFunc = completeFunc; + _completeStreamingFunc = completeStreamingFunc; + } + + /// + public override Task CompleteAsync( + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + if (_sharedFunc is not null) + { + return CompleteViaSharedAsync(chatMessages, options, cancellationToken); + + async Task CompleteViaSharedAsync(IList chatMessages, ChatOptions? options, CancellationToken cancellationToken) + { + ChatCompletion? completion = null; + await _sharedFunc(chatMessages, options, async (chatMessages, options, cancellationToken) => + { + completion = await InnerClient.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + + if (completion is null) + { + throw new InvalidOperationException("The wrapper completed successfully without producing a ChatCompletion."); + } + + return completion; + } + } + else if (_completeFunc is not null) + { + return _completeFunc(chatMessages, options, InnerClient, cancellationToken); + } + else + { + Debug.Assert(_completeStreamingFunc is not null, "Expected non-null streaming delegate."); + return _completeStreamingFunc!(chatMessages, options, InnerClient, cancellationToken) + .ToChatCompletionAsync(coalesceContent: true, cancellationToken); + } + } + + /// + public override IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chatMessages); + + if (_sharedFunc is not null) + { + var updates = Channel.CreateBounded(1); + +#pragma warning disable CA2016 // explicitly not forwarding the cancellation token, as we need to ensure the channel is always completed + _ = Task.Run(async () => +#pragma warning restore CA2016 + { + Exception? error = null; + try + { + await _sharedFunc(chatMessages, options, async (chatMessages, options, cancellationToken) => + { + await foreach (var update in InnerClient.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) + { + await updates.Writer.WriteAsync(update, cancellationToken).ConfigureAwait(false); + } + }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + _ = updates.Writer.TryComplete(error); + } + }); + + return updates.Reader.ReadAllAsync(cancellationToken); + } + else if (_completeStreamingFunc is not null) + { + return _completeStreamingFunc(chatMessages, options, InnerClient, cancellationToken); + } + else + { + Debug.Assert(_completeFunc is not null, "Expected non-null non-streaming delegate."); + return CompleteStreamingAsyncViaCompleteAsync(_completeFunc!(chatMessages, options, InnerClient, cancellationToken)); + + static async IAsyncEnumerable CompleteStreamingAsyncViaCompleteAsync(Task task) + { + ChatCompletion completion = await task.ConfigureAwait(false); + foreach (var update in completion.ToStreamingChatCompletionUpdates()) + { + yield return update; + } + } + } + } + + /// Throws an exception if both of the specified delegates are null. + /// Both and are . + internal static void ThrowIfBothDelegatesNull(object? completeFunc, object? completeStreamingFunc) + { + if (completeFunc is null && completeStreamingFunc is null) + { + Throw.ArgumentNullException(nameof(completeFunc), $"At least one of the {nameof(completeFunc)} or {nameof(completeStreamingFunc)} delegates must be non-null."); + } + } + + // Design note: + // The following delegate could juse use Func<...>, but it's defined as a custom delegate type + // in order to provide better discoverability / documentation / usability around its complicated + // signature with the nextAsync delegate parameter. + + /// + /// Represents a method used to call or . + /// + /// The chat content to send. + /// The chat options to configure the request. + /// + /// A delegate that provides the implementation for the inner client's or + /// . It should be invoked to continue the pipeline. It accepts + /// the chat messages, options, and cancellation token, which are typically the same instances as provided to this method + /// but need not be. + /// + /// The to monitor for cancellation requests. The default is . + /// A that represents the completion of the operation. + public delegate Task CompleteSharedFunc( + IList chatMessages, + ChatOptions? options, + Func, ChatOptions?, CancellationToken, Task> nextAsync, + CancellationToken cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs index e816101b4e1..83d7a749063 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientBuilder.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -76,4 +78,62 @@ public ChatClientBuilder Use(Func cl (_clientFactories ??= []).Add(clientFactory); return this; } + + /// + /// Adds to the chat client pipeline an anonymous delegating chat client based on a delegate that provides + /// an implementation for both and . + /// + /// + /// A delegate that provides the implementation for both and + /// . In addition to the arguments for the operation, it's + /// provided with a delegate to the inner client that should be used to perform the operation on the inner client. + /// It will handle both the non-streaming and streaming cases. + /// + /// The updated instance. + /// + /// This overload may be used when the anonymous implementation needs to provide pre- and/or post-processing, but doesn't + /// need to interact with the results of the operation, which will come from the inner client. + /// + /// is . + public ChatClientBuilder Use(AnonymousDelegatingChatClient.CompleteSharedFunc sharedFunc) + { + _ = Throw.IfNull(sharedFunc); + + return Use((innerClient, _) => new AnonymousDelegatingChatClient(innerClient, sharedFunc)); + } + + /// + /// Adds to the chat client pipeline an anonymous delegating chat client based on a delegate that provides + /// an implementation for both and . + /// + /// + /// A delegate that provides the implementation for . When , + /// must be non-null, and the implementation of + /// will use for the implementation. + /// + /// + /// A delegate that provides the implementation for . When , + /// must be non-null, and the implementation of + /// will use for the implementation. + /// + /// The updated instance. + /// + /// One or both delegates may be provided. If both are provided, they will be used for their respective methods: + /// will provide the implementation of , and + /// will provide the implementation of . + /// If only one of the delegates is provided, it will be used for both methods. That means that if + /// is supplied without , the implementation of + /// will employ limited streaming, as it will be operating on the batch output produced by . And if + /// is supplied without , the implementation of + /// will be implemented by combining the updates from . + /// + /// Both and are . + public ChatClientBuilder Use( + Func, ChatOptions?, IChatClient, CancellationToken, Task>? completeFunc, + Func, ChatOptions?, IChatClient, CancellationToken, IAsyncEnumerable>? completeStreamingFunc) + { + AnonymousDelegatingChatClient.ThrowIfBothDelegatesNull(completeFunc, completeStreamingFunc); + + return Use((innerClient, _) => new AnonymousDelegatingChatClient(innerClient, completeFunc, completeStreamingFunc)); + } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/AnonymousDelegatingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/AnonymousDelegatingEmbeddingGenerator.cs new file mode 100644 index 00000000000..9dd838be42e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/AnonymousDelegatingEmbeddingGenerator.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating embedding generator that wraps an inner generator with implementations provided by delegates. +/// The type from which embeddings will be generated. +/// The type of embeddings to generate. +public sealed class AnonymousDelegatingEmbeddingGenerator : DelegatingEmbeddingGenerator + where TEmbedding : Embedding +{ + /// The delegate to use as the implementation of . + private readonly Func, EmbeddingGenerationOptions?, IEmbeddingGenerator, CancellationToken, Task>> _generateFunc; + + /// Initializes a new instance of the class. + /// The inner generator. + /// A delegate that provides the implementation for . + /// is . + /// is . + public AnonymousDelegatingEmbeddingGenerator( + IEmbeddingGenerator innerGenerator, + Func, EmbeddingGenerationOptions?, IEmbeddingGenerator, CancellationToken, Task>> generateFunc) + : base(innerGenerator) + { + _ = Throw.IfNull(generateFunc); + + _generateFunc = generateFunc; + } + + /// + public override async Task> GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(values); + + return await _generateFunc(values, options, InnerGenerator, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs index 0e1620f748d..1baa64d2a20 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/EmbeddingGeneratorBuilder.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -74,7 +76,8 @@ public EmbeddingGeneratorBuilder Use(FuncAdds a factory for an intermediate embedding generator to the embedding generator pipeline. /// The generator factory function. /// The updated instance. - public EmbeddingGeneratorBuilder Use(Func, IServiceProvider, IEmbeddingGenerator> generatorFactory) + public EmbeddingGeneratorBuilder Use( + Func, IServiceProvider, IEmbeddingGenerator> generatorFactory) { _ = Throw.IfNull(generatorFactory); @@ -82,4 +85,21 @@ public EmbeddingGeneratorBuilder Use(Func + /// Adds to the embedding generator pipeline an anonymous delegating embedding generator based on a delegate that provides + /// an implementation for . + /// + /// + /// A delegate that provides the implementation for . + /// + /// The updated instance. + /// is . + public EmbeddingGeneratorBuilder Use( + Func, EmbeddingGenerationOptions?, IEmbeddingGenerator, CancellationToken, Task>>? generateFunc) + { + _ = Throw.IfNull(generateFunc); + + return Use((innerGenerator, _) => new AnonymousDelegatingEmbeddingGenerator(innerGenerator, generateFunc)); + } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index e4ebd6198a7..33628f2562a 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -38,6 +38,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/UseDelegateChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/UseDelegateChatClientTests.cs new file mode 100644 index 00000000000..1b331160316 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/UseDelegateChatClientTests.cs @@ -0,0 +1,255 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class UseDelegateChatClientTests +{ + [Fact] + public void InvalidArgs_Throws() + { + using var client = new TestChatClient(); + ChatClientBuilder builder = new(client); + + Assert.Throws("sharedFunc", () => + builder.Use((AnonymousDelegatingChatClient.CompleteSharedFunc)null!)); + + Assert.Throws("completeFunc", () => builder.Use(null!, null!)); + + Assert.Throws("innerClient", () => new AnonymousDelegatingChatClient(null!, delegate { return Task.CompletedTask; })); + Assert.Throws("sharedFunc", () => new AnonymousDelegatingChatClient(client, null!)); + + Assert.Throws("innerClient", () => new AnonymousDelegatingChatClient(null!, null!, null!)); + Assert.Throws("completeFunc", () => new AnonymousDelegatingChatClient(client, null!, null!)); + } + + [Fact] + public async Task Shared_ContextPropagated() + { + IList expectedMessages = []; + ChatOptions expectedOptions = new(); + using CancellationTokenSource expectedCts = new(); + + AsyncLocal asyncLocal = new(); + + using IChatClient innerClient = new TestChatClient + { + CompleteAsyncCallback = (chatMessages, options, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + Assert.Equal(42, asyncLocal.Value); + return Task.FromResult(new ChatCompletion(new ChatMessage(ChatRole.Assistant, "hello"))); + }, + + CompleteStreamingAsyncCallback = (chatMessages, options, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + Assert.Equal(42, asyncLocal.Value); + return YieldUpdates(new StreamingChatCompletionUpdate { Text = "world" }); + }, + }; + + using IChatClient client = new ChatClientBuilder(innerClient) + .Use(async (chatMessages, options, next, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + asyncLocal.Value = 42; + await next(chatMessages, options, cancellationToken); + }) + .Build(); + + Assert.Equal(0, asyncLocal.Value); + ChatCompletion completion = await client.CompleteAsync(expectedMessages, expectedOptions, expectedCts.Token); + Assert.Equal("hello", completion.Message.Text); + + Assert.Equal(0, asyncLocal.Value); + completion = await client.CompleteStreamingAsync(expectedMessages, expectedOptions, expectedCts.Token).ToChatCompletionAsync(); + Assert.Equal("world", completion.Message.Text); + } + + [Fact] + public async Task CompleteFunc_ContextPropagated() + { + IList expectedMessages = []; + ChatOptions expectedOptions = new(); + using CancellationTokenSource expectedCts = new(); + AsyncLocal asyncLocal = new(); + + using IChatClient innerClient = new TestChatClient + { + CompleteAsyncCallback = (chatMessages, options, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + Assert.Equal(42, asyncLocal.Value); + return Task.FromResult(new ChatCompletion(new ChatMessage(ChatRole.Assistant, "hello"))); + }, + }; + + using IChatClient client = new ChatClientBuilder(innerClient) + .Use(async (chatMessages, options, innerClient, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + asyncLocal.Value = 42; + var cc = await innerClient.CompleteAsync(chatMessages, options, cancellationToken); + cc.Choices[0].Text += " world"; + return cc; + }, null) + .Build(); + + Assert.Equal(0, asyncLocal.Value); + + ChatCompletion completion = await client.CompleteAsync(expectedMessages, expectedOptions, expectedCts.Token); + Assert.Equal("hello world", completion.Message.Text); + + completion = await client.CompleteStreamingAsync(expectedMessages, expectedOptions, expectedCts.Token).ToChatCompletionAsync(); + Assert.Equal("hello world", completion.Message.Text); + } + + [Fact] + public async Task CompleteStreamingFunc_ContextPropagated() + { + IList expectedMessages = []; + ChatOptions expectedOptions = new(); + using CancellationTokenSource expectedCts = new(); + AsyncLocal asyncLocal = new(); + + using IChatClient innerClient = new TestChatClient + { + CompleteStreamingAsyncCallback = (chatMessages, options, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + Assert.Equal(42, asyncLocal.Value); + return YieldUpdates(new StreamingChatCompletionUpdate { Text = "hello" }); + }, + }; + + using IChatClient client = new ChatClientBuilder(innerClient) + .Use(null, (chatMessages, options, innerClient, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + asyncLocal.Value = 42; + return Impl(chatMessages, options, innerClient, cancellationToken); + + static async IAsyncEnumerable Impl( + IList chatMessages, ChatOptions? options, IChatClient innerClient, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var update in innerClient.CompleteStreamingAsync(chatMessages, options, cancellationToken)) + { + yield return update; + } + + yield return new() { Text = " world" }; + } + }) + .Build(); + + Assert.Equal(0, asyncLocal.Value); + + ChatCompletion completion = await client.CompleteAsync(expectedMessages, expectedOptions, expectedCts.Token); + Assert.Equal("hello world", completion.Message.Text); + + completion = await client.CompleteStreamingAsync(expectedMessages, expectedOptions, expectedCts.Token).ToChatCompletionAsync(); + Assert.Equal("hello world", completion.Message.Text); + } + + [Fact] + public async Task BothCompleteAndCompleteStreamingFuncs_ContextPropagated() + { + IList expectedMessages = []; + ChatOptions expectedOptions = new(); + using CancellationTokenSource expectedCts = new(); + AsyncLocal asyncLocal = new(); + + using IChatClient innerClient = new TestChatClient + { + CompleteAsyncCallback = (chatMessages, options, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + Assert.Equal(42, asyncLocal.Value); + return Task.FromResult(new ChatCompletion(new ChatMessage(ChatRole.Assistant, "non-streaming hello"))); + }, + + CompleteStreamingAsyncCallback = (chatMessages, options, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + Assert.Equal(42, asyncLocal.Value); + return YieldUpdates(new StreamingChatCompletionUpdate { Text = "streaming hello" }); + }, + }; + + using IChatClient client = new ChatClientBuilder(innerClient) + .Use( + async (chatMessages, options, innerClient, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + asyncLocal.Value = 42; + var cc = await innerClient.CompleteAsync(chatMessages, options, cancellationToken); + cc.Choices[0].Text += " world (non-streaming)"; + return cc; + }, + (chatMessages, options, innerClient, cancellationToken) => + { + Assert.Same(expectedMessages, chatMessages); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + asyncLocal.Value = 42; + return Impl(chatMessages, options, innerClient, cancellationToken); + + static async IAsyncEnumerable Impl( + IList chatMessages, ChatOptions? options, IChatClient innerClient, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var update in innerClient.CompleteStreamingAsync(chatMessages, options, cancellationToken)) + { + yield return update; + } + + yield return new() { Text = " world (streaming)" }; + } + }) + .Build(); + + Assert.Equal(0, asyncLocal.Value); + + ChatCompletion completion = await client.CompleteAsync(expectedMessages, expectedOptions, expectedCts.Token); + Assert.Equal("non-streaming hello world (non-streaming)", completion.Message.Text); + + completion = await client.CompleteStreamingAsync(expectedMessages, expectedOptions, expectedCts.Token).ToChatCompletionAsync(); + Assert.Equal("streaming hello world (streaming)", completion.Message.Text); + } + + private static async IAsyncEnumerable YieldUpdates(params StreamingChatCompletionUpdate[] updates) + { + foreach (var update in updates) + { + await Task.Yield(); + yield return update; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/UseDelegateEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/UseDelegateEmbeddingGeneratorTests.cs new file mode 100644 index 00000000000..ab178727cb9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/UseDelegateEmbeddingGeneratorTests.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.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class UseDelegateEmbeddingGeneratorTests +{ + [Fact] + public void InvalidArgs_Throws() + { + using var generator = new TestEmbeddingGenerator(); + EmbeddingGeneratorBuilder> builder = new(generator); + + Assert.Throws("generateFunc", () => + builder.Use((Func, EmbeddingGenerationOptions?, IEmbeddingGenerator>, CancellationToken, Task>>>)null!)); + + Assert.Throws("innerGenerator", () => + new AnonymousDelegatingEmbeddingGenerator>( + null!, (values, options, innerGenerator, cancellationToken) => Task.FromResult(new GeneratedEmbeddings>(Array.Empty>())))); + + Assert.Throws("generateFunc", () => + new AnonymousDelegatingEmbeddingGenerator>(generator, null!)); + } + + [Fact] + public async Task GenerateFunc_ContextPropagated() + { + GeneratedEmbeddings> expectedEmbeddings = new(); + IList expectedValues = ["hello"]; + EmbeddingGenerationOptions expectedOptions = new(); + using CancellationTokenSource expectedCts = new(); + AsyncLocal asyncLocal = new(); + + using IEmbeddingGenerator> innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = (values, options, cancellationToken) => + { + Assert.Same(expectedValues, values); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + Assert.Equal(42, asyncLocal.Value); + return Task.FromResult(expectedEmbeddings); + }, + }; + + using IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>(innerGenerator) + .Use(async (values, options, innerGenerator, cancellationToken) => + { + Assert.Same(expectedValues, values); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCts.Token, cancellationToken); + asyncLocal.Value = 42; + var e = await innerGenerator.GenerateAsync(values, options, cancellationToken); + e.Add(new Embedding(default)); + return e; + }) + .Build(); + + Assert.Equal(0, asyncLocal.Value); + + GeneratedEmbeddings> actual = await generator.GenerateAsync(expectedValues, expectedOptions, expectedCts.Token); + Assert.Same(expectedEmbeddings, actual); + Assert.Single(actual); + } +} From 9b61daa94f745f4af0a9ef20880498eca6eb2a61 Mon Sep 17 00:00:00 2001 From: Amadeusz Lechniak Date: Tue, 19 Nov 2024 14:49:07 +0100 Subject: [PATCH 150/190] Update documentation SynchronizationContext in FakeTimeProvider (#5665) * Update documentation SynchronizationContext in FakeTimeProvider * Fix lint error * Update documentation --- .../README.md | 45 ++++++++----------- .../FakeTimeProviderTests.cs | 4 +- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/README.md b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/README.md index c1dfddbb9f6..f8faa6fdf2e 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/README.md +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/README.md @@ -42,18 +42,18 @@ timeProvider.Advance(TimeSpan.FromSeconds(5)); myComponent.CheckState(); ``` -## Use ConfigureAwait(true) with FakeTimeProvider.Advance +## SynchronizationContext in xUnit Tests -The Advance method is used to simulate the passage of time. This can be useful in tests where you need to control the timing of asynchronous operations. -When awaiting a task in a test that uses `FakeTimeProvider`, it's important to use `ConfigureAwait(true)`. +### xUnit v2 -Here's an example: +Some testing libraries such as xUnit v2 provide custom `SynchronizationContext` for running tests. xUnit v2, for instance, provides `AsyncTestSyncContext` that allows to properly manage asynchronous operations withing the test execution. However, it brings an issue when we test asynchronous code that uses `ConfigureAwait(false)` in combination with class like `FakeTimeProvider`. In such cases, the xUnit context may lose track of the continuation, causing the test to become unresponsive, whether the test itself is asynchronous or not. -```cs -await provider.Delay(TimeSpan.FromSeconds(delay)).ConfigureAwait(true); +To prevent this issue, remove the xUnit context for tests dependent on `FakeTimeProvider` by setting the synchronization context to `null`: +``` +SynchronizationContext.SetSynchronizationContext(null) ``` -This ensures that the continuation of the awaited task (i.e., the code that comes after the await statement) runs in the original context. +The `Advance` method is used to simulate the passage of time. Below is an example how to create a test for a code that uses `ConfigureAwait(false)` that ensures that the continuation of the awaited task (i.e., the code that comes after the await statement) works correctly. For a more realistic example, consider the following test using Polly: @@ -79,35 +79,21 @@ public class SomeService(TimeProvider timeProvider) public async Task PollyRetry(double taskDelay, double cancellationSeconds) { - CancellationTokenSource cts = new(TimeSpan.FromSeconds(cancellationSeconds), timeProvider); Tries = 0; - - // get a context from the pool and return it when done - var context = ResilienceContextPool.Shared.Get( - // ensure execution continues on captured context - continueOnCapturedContext: true, - cancellationToken: cts.Token); - - var result = await _retryPipeline.ExecuteAsync( + return await _retryPipeline.ExecuteAsync( async _ => { Tries++; - // Simulate a task that takes some time to complete - await Task.Delay(TimeSpan.FromSeconds(taskDelay), timeProvider).ConfigureAwait(true); - - if (Tries <= 2) + // With xUnit Context this would fail. + await timeProvider.Delay(TimeSpan.FromSeconds(taskDelay)).ConfigureAwait(false); + if (Tries < 2) { throw new InvalidOperationException(); } - return Tries; }, - context); - - ResilienceContextPool.Shared.Return(context); - - return result; + CancellationToken.None); } } @@ -118,6 +104,9 @@ public class SomeServiceTests [Fact] public void PollyRetry_ShouldHave2Tries() { + // Arrange + // Remove xUnit Context for this test + SynchronizationContext.SetSynchronizationContext(null); var timeProvider = new FakeTimeProvider(); var someService = new SomeService(timeProvider); @@ -138,6 +127,10 @@ public class SomeServiceTests } ``` +### xUnit v3 + +`AsyncTestSyncContext` has been removed more [here](https://xunit.net/docs/getting-started/v3/migration) so described issue is no longer a problem. + ## Feedback & Contributing We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs index 4f29e960975..58e218647b4 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Time.Testing; using Xunit; namespace Microsoft.Extensions.Time.Testing.Test; @@ -442,6 +441,7 @@ public void ShouldResetGateUnderLock_PreventingContextSwitching_AffectionOnTimer public void SimulateRetryPolicy() { // Arrange + SynchronizationContext.SetSynchronizationContext(null); var retries = 42; var tries = 0; var taskDelay = 0.5; @@ -469,7 +469,7 @@ async Task simulatedPollyRetry() catch (InvalidOperationException) { // ConfigureAwait(true) is required to ensure that tasks continue on the captured context - await provider.Delay(TimeSpan.FromSeconds(delay)).ConfigureAwait(true); + await provider.Delay(TimeSpan.FromSeconds(delay)).ConfigureAwait(false); } } } From 9cfd5ff4d51969b3857fd1e7d1b82e94383d4ce6 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 19 Nov 2024 18:41:33 +0000 Subject: [PATCH 151/190] Backport JsonSchemaExporter bugfix. (#5671) * Backport JsonSchemaExporter bugfix. * Address feedback. --- .../JsonSchemaExporter.JsonSchema.cs | 6 ++-- .../JsonSchemaExporter/JsonSchemaExporter.cs | 16 ++++----- .../JsonSchemaExporterTests.cs | 33 +++++++++++++++++++ test/Shared/JsonSchemaExporter/TestTypes.cs | 24 ++++++++++++++ 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs index 0f1044fc6eb..a395c133980 100644 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs @@ -17,8 +17,8 @@ internal static partial class JsonSchemaExporter // https://github.com/dotnet/runtime/blob/50d6cad649aad2bfa4069268eddd16fd51ec5cf3/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchema.cs private sealed class JsonSchema { - public static JsonSchema False { get; } = new(false); - public static JsonSchema True { get; } = new(true); + public static JsonSchema CreateFalseSchema() => new(false); + public static JsonSchema CreateTrueSchema() => new(true); public JsonSchema() { @@ -467,7 +467,7 @@ public static void EnsureMutable(ref JsonSchema schema) switch (schema._trueOrFalse) { case false: - schema = new JsonSchema { Not = JsonSchema.True }; + schema = new JsonSchema { Not = JsonSchema.CreateTrueSchema() }; break; case true: schema = new JsonSchema(); diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs index 5c6ce6d9ab7..2d8ffc5497c 100644 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs +++ b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs @@ -119,7 +119,7 @@ private static JsonSchema MapJsonSchemaCore( if (!ReflectionHelpers.IsBuiltInConverter(effectiveConverter)) { // Return a `true` schema for types with user-defined converters. - return CompleteSchema(ref state, JsonSchema.True); + return CompleteSchema(ref state, JsonSchema.CreateTrueSchema()); } if (parentPolymorphicTypeInfo is null && typeInfo.PolymorphismOptions is { DerivedTypes.Count: > 0 } polyOptions) @@ -245,7 +245,7 @@ private static JsonSchema MapJsonSchemaCore( if (effectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) { // Disallow unspecified properties. - additionalProperties = JsonSchema.False; + additionalProperties = JsonSchema.CreateFalseSchema(); } if (typeDiscriminator is { } typeDiscriminatorPair) @@ -435,7 +435,7 @@ private static JsonSchema MapJsonSchemaCore( } else { - schema = JsonSchema.True; + schema = JsonSchema.CreateTrueSchema(); } return CompleteSchema(ref state, schema); @@ -578,7 +578,7 @@ private static string FormatJsonPointer(ReadOnlySpan path) private static readonly Dictionary> _simpleTypeSchemaFactories = new() { - [typeof(object)] = _ => JsonSchema.True, + [typeof(object)] = _ => JsonSchema.CreateTrueSchema(), [typeof(bool)] = _ => new JsonSchema { Type = JsonSchemaType.Boolean }, [typeof(byte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), [typeof(ushort)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), @@ -625,10 +625,10 @@ private static string FormatJsonPointer(ReadOnlySpan path) Pattern = @"^\d+(\.\d+){1,3}$", }, - [typeof(JsonDocument)] = _ => JsonSchema.True, - [typeof(JsonElement)] = _ => JsonSchema.True, - [typeof(JsonNode)] = _ => JsonSchema.True, - [typeof(JsonValue)] = _ => JsonSchema.True, + [typeof(JsonDocument)] = _ => JsonSchema.CreateTrueSchema(), + [typeof(JsonElement)] = _ => JsonSchema.CreateTrueSchema(), + [typeof(JsonNode)] = _ => JsonSchema.CreateTrueSchema(), + [typeof(JsonValue)] = _ => JsonSchema.CreateTrueSchema(), [typeof(JsonObject)] = _ => new JsonSchema { Type = JsonSchemaType.Object }, [typeof(JsonArray)] = _ => new JsonSchema { Type = JsonSchemaType.Array }, }; diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs index 2ec81987dc2..70babf81334 100644 --- a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs +++ b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs @@ -13,6 +13,7 @@ using System.Xml.Linq; #endif using Xunit; +using static Microsoft.Extensions.AI.JsonSchemaExporter.TestTypes; #pragma warning disable SA1402 // File may only contain a single type @@ -86,6 +87,38 @@ public void CanGenerateXElementSchema() } #endif +#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/109954 gets backported + [Fact] + public void TransformSchemaNode_PropertiesWithCustomConverters() + { + // Regression test for https://github.com/dotnet/runtime/issues/109868 + List<(Type? parentType, string? propertyName, Type type)> visitedNodes = new(); + JsonSchemaExporterOptions exporterOptions = new() + { + TransformSchemaNode = (ctx, schema) => + { +#if NET9_0_OR_GREATER + visitedNodes.Add((ctx.PropertyInfo?.DeclaringType, ctx.PropertyInfo?.Name, ctx.TypeInfo.Type)); +#else + visitedNodes.Add((ctx.DeclaringType, ctx.PropertyInfo?.Name, ctx.TypeInfo.Type)); +#endif + return schema; + } + }; + + List<(Type? parentType, string? propertyName, Type type)> expectedNodes = + [ + (typeof(ClassWithPropertiesUsingCustomConverters), "Prop1", typeof(ClassWithPropertiesUsingCustomConverters.ClassWithCustomConverter1)), + (typeof(ClassWithPropertiesUsingCustomConverters), "Prop2", typeof(ClassWithPropertiesUsingCustomConverters.ClassWithCustomConverter2)), + (null, null, typeof(ClassWithPropertiesUsingCustomConverters)), + ]; + + Options.GetJsonSchemaAsNode(typeof(ClassWithPropertiesUsingCustomConverters), exporterOptions); + + Assert.Equal(expectedNodes, visitedNodes); + } +#endif + [Fact] public void TreatNullObliviousAsNonNullable_True_DoesNotImpactObjectType() { diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index d21a40640dd..0ac4ca2bf18 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -1164,6 +1164,29 @@ public readonly struct StructDictionary(IEnumerable ((IEnumerable)_dictionary).GetEnumerator(); } + public class ClassWithPropertiesUsingCustomConverters + { + [JsonPropertyOrder(0)] + public ClassWithCustomConverter1? Prop1 { get; set; } + [JsonPropertyOrder(1)] + public ClassWithCustomConverter2? Prop2 { get; set; } + + [JsonConverter(typeof(CustomConverter))] + public class ClassWithCustomConverter1; + + [JsonConverter(typeof(CustomConverter))] + public class ClassWithCustomConverter2; + + public sealed class CustomConverter : JsonConverter + { + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => default; + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + => writer.WriteNullValue(); + } + } + [JsonSerializable(typeof(object))] [JsonSerializable(typeof(bool))] [JsonSerializable(typeof(byte))] @@ -1248,6 +1271,7 @@ public readonly struct StructDictionary(IEnumerable))] From f802390fbb970266815e7763b84d1dc814e1696d Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 19 Nov 2024 16:45:31 -0500 Subject: [PATCH 152/190] Add OpenAIRealtimeExtensions with ToConversationFunctionTool (#5666) --- eng/packages/General.props | 2 +- eng/packages/TestOnly.props | 2 +- .../Microsoft.Extensions.AI.OpenAI.csproj | 2 +- .../OpenAIChatClient.cs | 14 +- .../OpenAIJsonContext.cs | 16 ++ .../OpenAIRealtimeExtensions.cs | 156 ++++++++++++++++++ ...icrosoft.Extensions.AI.OpenAI.Tests.csproj | 2 + .../OpenAIRealtimeIntegrationTests.cs | 114 +++++++++++++ .../OpenAIRealtimeTests.cs | 121 ++++++++++++++ 9 files changed, 415 insertions(+), 14 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeTests.cs diff --git a/eng/packages/General.props b/eng/packages/General.props index 9c54a2351ab..ff2c3010128 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -11,7 +11,7 @@ - + diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index d9802530ed3..6443d61c224 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -2,7 +2,7 @@ - + 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 f2e2e9c0f52..1d400389af0 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 @@ -15,7 +15,7 @@ $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002 + $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002;OPENAI002 true true diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 90329a9b593..05bd801ac09 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -24,7 +24,7 @@ namespace Microsoft.Extensions.AI; /// Represents an for an OpenAI or . -public sealed partial class OpenAIChatClient : IChatClient +public sealed class OpenAIChatClient : IChatClient { private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; @@ -513,14 +513,14 @@ strictObj is bool strictValue ? } resultParameters = BinaryData.FromBytes( - JsonSerializer.SerializeToUtf8Bytes(tool, JsonContext.Default.OpenAIChatToolJson)); + JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.OpenAIChatToolJson)); } return ChatTool.CreateFunctionTool(aiFunction.Metadata.Name, aiFunction.Metadata.Description, resultParameters, strict); } /// Used to create the JSON payload for an OpenAI chat tool description. - private sealed class OpenAIChatToolJson + internal sealed class OpenAIChatToolJson { /// Gets a singleton JSON data for empty parameters. Optimization for the reasonably common case of a parameterless function. public static BinaryData ZeroFunctionParametersSchema { get; } = new("""{"type":"object","required":[],"properties":{}}"""u8.ToArray()); @@ -681,12 +681,4 @@ private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8 FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, argumentParser: static json => JsonSerializer.Deserialize(json, (JsonTypeInfo>)AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))!); - - /// Source-generated JSON type information. - [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true)] - [JsonSerializable(typeof(OpenAIChatToolJson))] - private sealed partial class JsonContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs new file mode 100644 index 00000000000..9cd075e1d04 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Source-generated JSON type information. +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] +[JsonSerializable(typeof(OpenAIChatClient.OpenAIChatToolJson))] +[JsonSerializable(typeof(OpenAIRealtimeExtensions.ConversationFunctionToolParametersSchema))] +internal sealed partial class OpenAIJsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs new file mode 100644 index 00000000000..c47cfc52c21 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeExtensions.cs @@ -0,0 +1,156 @@ +// 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.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using OpenAI.RealtimeConversation; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods for working with and related types. +/// +public static class OpenAIRealtimeExtensions +{ + private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; + + /// + /// Converts a into a so that + /// it can be used with . + /// + /// A that can be used with . + public static ConversationFunctionTool ToConversationFunctionTool(this AIFunction aiFunction) + { + _ = Throw.IfNull(aiFunction); + + var parametersSchema = new ConversationFunctionToolParametersSchema + { + Type = "object", + Properties = aiFunction.Metadata.Parameters + .ToDictionary(p => p.Name, GetParameterSchema), + Required = aiFunction.Metadata.Parameters + .Where(p => p.IsRequired) + .Select(p => p.Name), + }; + + return new ConversationFunctionTool + { + Name = aiFunction.Metadata.Name, + Description = aiFunction.Metadata.Description, + Parameters = new BinaryData(JsonSerializer.SerializeToUtf8Bytes( + parametersSchema, OpenAIJsonContext.Default.ConversationFunctionToolParametersSchema)) + }; + } + + /// + /// Handles tool calls. + /// + /// If the represents a tool call, calls the corresponding tool and + /// adds the result to the . + /// + /// If the represents the end of a response, checks if this was due + /// to a tool call and if so, instructs the to begin responding to it. + /// + /// The . + /// The being processed. + /// The available tools. + /// An optional flag specifying whether to disclose detailed exception information to the model. The default value is . + /// An optional that controls JSON handling. + /// An optional . + /// A that represents the completion of processing, including invoking any asynchronous tools. + public static async Task HandleToolCallsAsync( + this RealtimeConversationSession session, + ConversationUpdate update, + IReadOnlyList tools, + bool? detailedErrors = false, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(session); + _ = Throw.IfNull(update); + _ = Throw.IfNull(tools); + + if (update is ConversationItemStreamingFinishedUpdate itemFinished) + { + // If we need to call a tool to update the model, do so + if (!string.IsNullOrEmpty(itemFinished.FunctionName) + && await itemFinished.GetFunctionCallOutputAsync(tools, detailedErrors, jsonSerializerOptions, cancellationToken).ConfigureAwait(false) is { } output) + { + await session.AddItemAsync(output, cancellationToken).ConfigureAwait(false); + } + } + else if (update is ConversationResponseFinishedUpdate responseFinished) + { + // If we added one or more function call results, instruct the model to respond to them + if (responseFinished.CreatedItems.Any(item => !string.IsNullOrEmpty(item.FunctionName))) + { + await session!.StartResponseAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + private static JsonElement GetParameterSchema(AIFunctionParameterMetadata parameterMetadata) + { + return parameterMetadata switch + { + { Schema: JsonElement jsonElement } => jsonElement, + _ => _defaultParameterSchema, + }; + } + + private static async Task GetFunctionCallOutputAsync( + this ConversationItemStreamingFinishedUpdate update, + IReadOnlyList tools, + bool? detailedErrors = false, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrEmpty(update.FunctionName) + && tools.FirstOrDefault(t => t.Metadata.Name == update.FunctionName) is AIFunction aiFunction) + { + var jsonOptions = jsonSerializerOptions ?? AIJsonUtilities.DefaultOptions; + + var functionCallContent = FunctionCallContent.CreateFromParsedArguments( + update.FunctionCallArguments, update.FunctionCallId, update.FunctionName, + argumentParser: json => JsonSerializer.Deserialize(json, + (JsonTypeInfo>)jsonOptions.GetTypeInfo(typeof(IDictionary)))!); + + try + { + var result = await aiFunction.InvokeAsync(functionCallContent.Arguments, cancellationToken).ConfigureAwait(false); + var resultJson = JsonSerializer.Serialize(result, jsonOptions.GetTypeInfo(typeof(object))); + return ConversationItem.CreateFunctionCallOutput(update.FunctionCallId, resultJson); + } + catch (JsonException) + { + return ConversationItem.CreateFunctionCallOutput(update.FunctionCallId, "Invalid JSON"); + } + catch (Exception e) when (!cancellationToken.IsCancellationRequested) + { + var message = "Error calling tool"; + + if (detailedErrors == true) + { + message += $": {e.Message}"; + } + + return ConversationItem.CreateFunctionCallOutput(update.FunctionCallId, message); + } + } + + return null; + } + + internal sealed class ConversationFunctionToolParametersSchema + { + public string? Type { get; set; } + public IDictionary? Properties { get; set; } + public IEnumerable? Required { get; set; } + } +} 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 0ef40e12df3..66412bfeace 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,10 +2,12 @@ Microsoft.Extensions.AI Unit tests for Microsoft.Extensions.AI.OpenAI + $(NoWarn);OPENAI002 true + true diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs new file mode 100644 index 00000000000..46b9fac7cab --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Microsoft.TestUtilities; +using OpenAI.RealtimeConversation; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIRealtimeIntegrationTests +{ + private RealtimeConversationClient? _conversationClient; + + public OpenAIRealtimeIntegrationTests() + { + _conversationClient = CreateConversationClient(); + } + + [ConditionalFact] + public async Task CanPerformFunctionCall() + { + SkipIfNotEnabled(); + + var roomCapacityTool = AIFunctionFactory.Create(GetRoomCapacity); + var sessionOptions = new ConversationSessionOptions + { + Instructions = "You help with booking appointments", + Tools = { roomCapacityTool.ToConversationFunctionTool() }, + ContentModalities = ConversationContentModalities.Text, + }; + + using var session = await _conversationClient.StartConversationSessionAsync(); + await session.ConfigureSessionAsync(sessionOptions); + + await foreach (var update in session.ReceiveUpdatesAsync()) + { + switch (update) + { + case ConversationSessionStartedUpdate: + await session.AddItemAsync( + ConversationItem.CreateUserMessage([""" + What type of room can hold the most people? + Reply with the full name of the biggest venue and its capacity only. + Do not mention the other venues. + """])); + await session.StartResponseAsync(); + break; + + case ConversationResponseFinishedUpdate responseFinished: + var content = responseFinished.CreatedItems + .SelectMany(i => i.MessageContentParts ?? []) + .OfType() + .FirstOrDefault(); + if (content is not null) + { + Assert.Contains("VehicleAssemblyBuilding", content.Text.Replace(" ", string.Empty)); + Assert.Contains("12000", content.Text.Replace(",", string.Empty)); + return; + } + + break; + } + + await session.HandleToolCallsAsync(update, [roomCapacityTool]); + } + } + + [Description("Returns the number of people that can fit in a room.")] + private static int GetRoomCapacity(RoomType roomType) + { + return roomType switch + { + RoomType.ShuttleSimulator => throw new InvalidOperationException("No longer available"), + RoomType.NorthAtlantisLawn => 450, + RoomType.VehicleAssemblyBuilding => 12000, + _ => throw new NotSupportedException($"Unknown room type: {roomType}"), + }; + } + + private enum RoomType + { + ShuttleSimulator, + NorthAtlantisLawn, + VehicleAssemblyBuilding, + } + + [MemberNotNull(nameof(_conversationClient))] + protected void SkipIfNotEnabled() + { + if (_conversationClient is null) + { + throw new SkipTestException("Client is not enabled."); + } + } + + private static RealtimeConversationClient? CreateConversationClient() + { + var realtimeModel = Environment.GetEnvironmentVariable("OPENAI_REALTIME_MODEL"); + if (string.IsNullOrEmpty(realtimeModel)) + { + return null; + } + + var openAiClient = (AzureOpenAIClient?)IntegrationTestHelpers.GetOpenAIClient(); + return openAiClient?.GetRealtimeConversationClient(realtimeModel); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeTests.cs new file mode 100644 index 00000000000..32e4d059a51 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeTests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using System.ComponentModel; +using System.Threading.Tasks; +using OpenAI.RealtimeConversation; +using Xunit; + +namespace Microsoft.Extensions.AI; + +// Note that we're limited on ability to unit-test OpenAIRealtimeExtension, because some of the +// OpenAI types it uses (e.g., ConversationItemStreamingFinishedUpdate) can't be instantiated or +// subclassed from outside. We will mostly have to rely on integration tests for now. + +public class OpenAIRealtimeTests +{ + [Fact] + public void ConvertsAIFunctionToConversationFunctionTool_Basics() + { + var input = AIFunctionFactory.Create(() => { }, "MyFunction", "MyDescription"); + var result = input.ToConversationFunctionTool(); + + Assert.Equal("MyFunction", result.Name); + Assert.Equal("MyDescription", result.Description); + } + + [Fact] + public void ConvertsAIFunctionToConversationFunctionTool_Parameters() + { + var input = AIFunctionFactory.Create(MyFunction); + var result = input.ToConversationFunctionTool(); + + Assert.Equal(nameof(MyFunction), result.Name); + Assert.Equal("This is a description", result.Description); + Assert.Equal(""" + { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "description": "Another param", + "type": "string" + }, + "c": { + "type": "object", + "properties": { + "a": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "a" + ], + "default": "null" + } + }, + "required": [ + "a", + "b" + ] + } + """, result.Parameters.ToString()); + } + + [Fact] + public async Task HandleToolCallsAsync_RejectsNulls() + { + var conversationSession = (RealtimeConversationSession)default!; + + // Null RealtimeConversationSession + await Assert.ThrowsAsync(() => conversationSession.HandleToolCallsAsync( + new TestConversationUpdate(), [])); + + // Null ConversationUpdate + using var session = TestRealtimeConversationSession.CreateTestInstance(); + await Assert.ThrowsAsync(() => conversationSession.HandleToolCallsAsync( + null!, [])); + + // Null tools + await Assert.ThrowsAsync(() => conversationSession.HandleToolCallsAsync( + new TestConversationUpdate(), null!)); + } + + [Description("This is a description")] + private MyType MyFunction(int a, [Description("Another param")] string b, MyType? c = null) + => throw new NotSupportedException(); + + public class MyType + { + public int A { get; set; } + } + + private class TestRealtimeConversationSession : RealtimeConversationSession + { + protected internal TestRealtimeConversationSession(RealtimeConversationClient parentClient, Uri endpoint, ApiKeyCredential credential) + : base(parentClient, endpoint, credential) + { + } + + public static TestRealtimeConversationSession CreateTestInstance() + { + var credential = new ApiKeyCredential("key"); + return new TestRealtimeConversationSession( + new RealtimeConversationClient("model", credential), + new Uri("http://endpoint"), credential); + } + } + + private class TestConversationUpdate : ConversationUpdate + { + public TestConversationUpdate() + : base("eventId") + { + } + } +} From 476a196058698c6992e002577d3886078194abf2 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 19 Nov 2024 17:07:49 -0500 Subject: [PATCH 153/190] Tweak CachingHelpers.GetCacheKey to clean up better on failure (#5654) --- .../Microsoft.Extensions.AI/CachingHelpers.cs | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs b/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs index 102fc86b138..3b5f5531755 100644 --- a/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs +++ b/src/Libraries/Microsoft.Extensions.AI/CachingHelpers.cs @@ -36,19 +36,41 @@ public static string GetCacheKey(ReadOnlySpan values, JsonSerializerOpt // invalidating any existing cache entries that may exist in whatever IDistributedCache was in use. #if NET - IncrementalHashStream? stream = IncrementalHashStream.ThreadStaticInstance ?? new(); - IncrementalHashStream.ThreadStaticInstance = null; + IncrementalHashStream? stream = IncrementalHashStream.ThreadStaticInstance; + if (stream is not null) + { + // We need to ensure that the value in ThreadStaticInstance is always ready to use. + // If we start using an instance, write to it, and then fail, we will have left it + // in an inconsistent state. So, when renting it, we null it out, and we only put + // it back upon successful completion after resetting it. + IncrementalHashStream.ThreadStaticInstance = null; + } + else + { + stream = new(); + } - foreach (object? value in values) + string result; + try { - JsonSerializer.Serialize(stream, value, serializerOptions.GetTypeInfo(typeof(object))); + foreach (object? value in values) + { + JsonSerializer.Serialize(stream, value, serializerOptions.GetTypeInfo(typeof(object))); + } + + Span hashData = stackalloc byte[SHA256.HashSizeInBytes]; + stream.GetHashAndReset(hashData); + + result = Convert.ToHexString(hashData); + } + catch + { + stream.Dispose(); + throw; } - Span hashData = stackalloc byte[SHA256.HashSizeInBytes]; - stream.GetHashAndReset(hashData); IncrementalHashStream.ThreadStaticInstance = stream; - - return Convert.ToHexString(hashData); + return result; #else MemoryStream stream = new(); foreach (object? value in values) @@ -57,7 +79,6 @@ public static string GetCacheKey(ReadOnlySpan values, JsonSerializerOpt } using var sha256 = SHA256.Create(); - stream.Position = 0; var hashData = sha256.ComputeHash(stream.GetBuffer(), 0, (int)stream.Length); var chars = new char[hashData.Length * 2]; From 7ebb34d4b5a0d1e18c4323cbb23a7e1b6c4622bc Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 20 Nov 2024 10:27:18 -0500 Subject: [PATCH 154/190] Ensure non-streaming usage data from function calling is in history (#5676) It's already yielded during streaming, but it's not being surfaced for non-streaming. Do so by manufacturing a new UsageContent for the UsageDetails and adding that to the response message that's added to the history. --- .../ChatCompletion/FunctionInvokingChatClient.cs | 7 +++++++ .../ChatClientIntegrationTests.cs | 14 +++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 1366422b8ea..70fddc68718 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -252,6 +252,13 @@ public override async Task CompleteAsync(IList chat } } + // If the original chat completion included usage data, + // add that into the message so it's available in the history. + if (KeepFunctionCallingMessages && response.Usage is { } usage) + { + response.Message.Contents = [.. response.Message.Contents, new UsageContent(usage)]; + } + // Add the responses from the function calls into the history. var modeAndMessages = await ProcessFunctionCallsAsync(chatMessages, options, functionCallContents, iteration, cancellationToken).ConfigureAwait(false); if (modeAndMessages.MessagesAdded is not null) diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index cf113c878f6..ab8e7613edb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -163,13 +163,25 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_Paramet int secretNumber = 42; - var response = await chatClient.CompleteAsync("What is the current secret number?", new() + List messages = + [ + new(ChatRole.User, "What is the current secret number?") + ]; + + var response = await chatClient.CompleteAsync(messages, new() { Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")] }); Assert.Single(response.Choices); Assert.Contains(secretNumber.ToString(), response.Message.Text); + + if (response.Usage is { } finalUsage) + { + UsageContent? intermediate = messages.SelectMany(m => m.Contents).OfType().FirstOrDefault(); + Assert.NotNull(intermediate); + Assert.True(finalUsage.TotalTokenCount > intermediate.Details.TotalTokenCount); + } } [ConditionalFact] From 1a4a54f107eb2651d951934050455a96bface2a8 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 20 Nov 2024 14:06:53 -0500 Subject: [PATCH 155/190] Fix a few FunctionInvocationChatClient streaming issues (#5680) - The non-streaming path explicitly throws if the response contains multiple choices. The streaming path wasn't doing the same and was instead silently producing bad results. - The streaming path was yielding function call content _and_ adding them to the chat history. It should only have been doing the latter. This fixes both issues. We also had close to zero test coverage in our FunctionInvocationChatClient tests for streaming, only for non-streaming. This also fixes that. --- .../FunctionInvokingChatClient.cs | 51 +- .../FunctionInvokingChatClientTests.cs | 476 ++++++++++++------ 2 files changed, 371 insertions(+), 156 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 70fddc68718..e1e4542d5d0 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -216,7 +216,7 @@ public override async Task CompleteAsync(IList chat // doesn't realize this and is wasting their budget requesting extra choices we'd never use. if (response.Choices.Count > 1) { - throw new InvalidOperationException($"Automatic function call invocation only accepts a single choice, but {response.Choices.Count} choices were received."); + ThrowForMultipleChoices(); } // Extract any function call contents on the first choice. If there are none, we're done. @@ -301,22 +301,47 @@ public override async IAsyncEnumerable CompleteSt _ = Throw.IfNull(chatMessages); HashSet? messagesToRemove = null; + List functionCallContents = []; + int? choice; try { for (int iteration = 0; ; iteration++) { - List? functionCallContents = null; - await foreach (var chunk in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) + choice = null; + functionCallContents.Clear(); + await foreach (var update in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false)) { // We're going to emit all StreamingChatMessage items upstream, even ones that represent - // function calls, because a given StreamingChatMessage can contain other content too. - yield return chunk; + // function calls, because a given StreamingChatMessage can contain other content, too. + // And if we yield the function calls, and the consumer adds all the content into a message + // that's then added into history, they'll end up with function call contents that aren't + // directly paired with function result contents, which may cause issues for some models + // when the history is later sent again. + + // Find all the FCCs. We need to track these separately in order to be able to process them later. + int preFccCount = functionCallContents.Count; + functionCallContents.AddRange(update.Contents.OfType()); + + // If there were any, remove them from the update. We do this before yielding the update so + // that we're not modifying an instance already provided back to the caller. + int addedFccs = functionCallContents.Count - preFccCount; + if (addedFccs > preFccCount) + { + update.Contents = addedFccs == update.Contents.Count ? + [] : update.Contents.Where(c => c is not FunctionCallContent).ToList(); + } - foreach (var item in chunk.Contents.OfType()) + // Only one choice is allowed with automatic function calling. + if (choice is null) + { + choice = update.ChoiceIndex; + } + else if (choice != update.ChoiceIndex) { - functionCallContents ??= []; - functionCallContents.Add(item); + ThrowForMultipleChoices(); } + + yield return update; } // If there are no tools to call, or for any other reason we should stop, return the response. @@ -373,6 +398,16 @@ public override async IAsyncEnumerable CompleteSt } } + /// Throws an exception when multiple choices are received. + private static void ThrowForMultipleChoices() + { + // If there's more than one choice, we don't know which one to add to chat history, or which + // of their function calls to process. This should not happen except if the developer has + // explicitly requested multiple choices. We fail aggressively to avoid cases where a developer + // doesn't realize this and is wasting their budget requesting extra choices we'd never use. + throw new InvalidOperationException("Automatic function call invocation only accepts a single choice, but multiple choices were received."); + } + /// /// Removes all of the messages in from /// and all of the content in from the messages in . diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index d9df2fc89e3..da983243acb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -12,6 +12,8 @@ using OpenTelemetry.Trace; using Xunit; +#pragma warning disable SA1118 // Parameter should not span multiple lines + namespace Microsoft.Extensions.AI; public class FunctionInvokingChatClientTests @@ -41,14 +43,16 @@ public async Task SupportsSingleFunctionCallPerRequestAsync() { var options = new ChatOptions { - Tools = [ + Tools = + [ AIFunctionFactory.Create(() => "Result 1", "Func1"), AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), AIFunctionFactory.Create((int i) => { }, "VoidReturn"), ] }; - await InvokeAndAssertAsync(options, [ + List plan = + [ new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]), @@ -57,7 +61,11 @@ await InvokeAndAssertAsync(options, [ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", "VoidReturn", result: "Success: Function completed.")]), new ChatMessage(ChatRole.Assistant, "world"), - ]); + ]; + + await InvokeAndAssertAsync(options, plan); + + await InvokeAndAssertStreamingAsync(options, plan); } [Theory] @@ -67,31 +75,46 @@ public async Task SupportsMultipleFunctionCallsPerRequestAsync(bool concurrentIn { var options = new ChatOptions { - Tools = [ + Tools = + [ AIFunctionFactory.Create((int i) => "Result 1", "Func1"), AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), ] }; - await InvokeAndAssertAsync(options, [ + + List plan = + [ new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [ + new ChatMessage(ChatRole.Assistant, + [ new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 34 } }), new FunctionCallContent("callId3", "Func2", arguments: new Dictionary { { "i", 56 } }), ]), - new ChatMessage(ChatRole.Tool, [ + new ChatMessage(ChatRole.Tool, + [ new FunctionResultContent("callId1", "Func1", result: "Result 1"), new FunctionResultContent("callId2", "Func2", result: "Result 2: 34"), new FunctionResultContent("callId3", "Func2", result: "Result 2: 56"), ]), - new ChatMessage(ChatRole.Assistant, [ + new ChatMessage(ChatRole.Assistant, + [ new FunctionCallContent("callId4", "Func2", arguments: new Dictionary { { "i", 78 } }), - new FunctionCallContent("callId5", "Func1")]), - new ChatMessage(ChatRole.Tool, [ + new FunctionCallContent("callId5", "Func1") + ]), + new ChatMessage(ChatRole.Tool, + [ new FunctionResultContent("callId4", "Func2", result: "Result 2: 78"), - new FunctionResultContent("callId5", "Func1", result: "Result 1")]), + new FunctionResultContent("callId5", "Func1", result: "Result 1") + ]), new ChatMessage(ChatRole.Assistant, "world"), - ], configurePipeline: b => b.Use(s => new FunctionInvokingChatClient(s) { ConcurrentInvocation = concurrentInvocation })); + ]; + + Func configure = b => b.Use(s => new FunctionInvokingChatClient(s) { ConcurrentInvocation = concurrentInvocation }); + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } [Fact] @@ -101,7 +124,8 @@ public async Task ParallelFunctionCallsMayBeInvokedConcurrentlyAsync() var options = new ChatOptions { - Tools = [ + Tools = + [ AIFunctionFactory.Create((string arg) => { barrier.SignalAndWait(); @@ -110,18 +134,27 @@ public async Task ParallelFunctionCallsMayBeInvokedConcurrentlyAsync() ] }; - await InvokeAndAssertAsync(options, [ + List plan = + [ new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [ + new ChatMessage(ChatRole.Assistant, + [ new FunctionCallContent("callId1", "Func", arguments: new Dictionary { { "arg", "hello" } }), new FunctionCallContent("callId2", "Func", arguments: new Dictionary { { "arg", "world" } }), ]), - new ChatMessage(ChatRole.Tool, [ + new ChatMessage(ChatRole.Tool, + [ new FunctionResultContent("callId1", "Func", result: "hellohello"), new FunctionResultContent("callId2", "Func", result: "worldworld"), ]), new ChatMessage(ChatRole.Assistant, "done"), - ], configurePipeline: b => b.Use(s => new FunctionInvokingChatClient(s) { ConcurrentInvocation = true })); + ]; + + Func configure = b => b.Use(s => new FunctionInvokingChatClient(s) { ConcurrentInvocation = true }); + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } [Fact] @@ -131,7 +164,8 @@ public async Task ConcurrentInvocationOfParallelCallsDisabledByDefaultAsync() var options = new ChatOptions { - Tools = [ + Tools = + [ AIFunctionFactory.Create(async (string arg) => { Interlocked.Increment(ref activeCount); @@ -143,18 +177,25 @@ public async Task ConcurrentInvocationOfParallelCallsDisabledByDefaultAsync() ] }; - await InvokeAndAssertAsync(options, [ + List plan = + [ new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [ + new ChatMessage(ChatRole.Assistant, + [ new FunctionCallContent("callId1", "Func", arguments: new Dictionary { { "arg", "hello" } }), new FunctionCallContent("callId2", "Func", arguments: new Dictionary { { "arg", "world" } }), ]), - new ChatMessage(ChatRole.Tool, [ + new ChatMessage(ChatRole.Tool, + [ new FunctionResultContent("callId1", "Func", result: "hellohello"), new FunctionResultContent("callId2", "Func", result: "worldworld"), ]), new ChatMessage(ChatRole.Assistant, "done"), - ]); + ]; + + await InvokeAndAssertAsync(options, plan); + + await InvokeAndAssertStreamingAsync(options, plan); } [Theory] @@ -172,36 +213,40 @@ public async Task RemovesFunctionCallingMessagesWhenRequestedAsync(bool keepFunc ] }; -#pragma warning disable SA1118 // Parameter should not span multiple lines - var finalChat = await InvokeAndAssertAsync( - options, - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", "VoidReturn", result: "Success: Function completed.")]), - new ChatMessage(ChatRole.Assistant, "world"), - ], - expected: keepFunctionCallingMessages ? - null : - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, "world") - ], - configurePipeline: b => b.Use(client => new FunctionInvokingChatClient(client) { KeepFunctionCallingMessages = keepFunctionCallingMessages })); -#pragma warning restore SA1118 - - IEnumerable content = finalChat.SelectMany(m => m.Contents); - if (keepFunctionCallingMessages) - { - Assert.Contains(content, c => c is FunctionCallContent or FunctionResultContent); - } - else + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", "VoidReturn", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List? expected = keepFunctionCallingMessages ? null : + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, "world") + ]; + + Func configure = b => b.Use(client => new FunctionInvokingChatClient(client) { KeepFunctionCallingMessages = keepFunctionCallingMessages }); + + Validate(await InvokeAndAssertAsync(options, plan, expected, configure)); + Validate(await InvokeAndAssertStreamingAsync(options, plan, expected, configure)); + + void Validate(List finalChat) { - Assert.All(content, c => Assert.False(c is FunctionCallContent or FunctionResultContent)); + IEnumerable content = finalChat.SelectMany(m => m.Contents); + if (keepFunctionCallingMessages) + { + Assert.Contains(content, c => c is FunctionCallContent or FunctionResultContent); + } + else + { + Assert.All(content, c => Assert.False(c is FunctionCallContent or FunctionResultContent)); + } } } @@ -220,37 +265,56 @@ public async Task RemovesFunctionCallingContentWhenRequestedAsync(bool keepFunct ] }; -#pragma warning disable SA1118 // Parameter should not span multiple lines - var finalChat = await InvokeAndAssertAsync(options, - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new TextContent("extra"), new FunctionCallContent("callId1", "Func1"), new TextContent("stuff")]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func1", result: "Result 1")]), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } }), new TextContent("more")]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", "VoidReturn", result: "Success: Function completed.")]), - new ChatMessage(ChatRole.Assistant, "world"), - ], - expected: keepFunctionCallingMessages ? - null : - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new TextContent("extra"), new TextContent("stuff")]), - new ChatMessage(ChatRole.Assistant, "more"), - new ChatMessage(ChatRole.Assistant, "world"), - ], - configurePipeline: b => b.Use(client => new FunctionInvokingChatClient(client) { KeepFunctionCallingMessages = keepFunctionCallingMessages })); -#pragma warning restore SA1118 - - IEnumerable content = finalChat.SelectMany(m => m.Contents); - if (keepFunctionCallingMessages) - { - Assert.Contains(content, c => c is FunctionCallContent or FunctionResultContent); - } - else + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new TextContent("extra"), new FunctionCallContent("callId1", "Func1"), new TextContent("stuff")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } }), new TextContent("more")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", "VoidReturn", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + Func configure = b => b.Use(client => new FunctionInvokingChatClient(client) { KeepFunctionCallingMessages = keepFunctionCallingMessages }); + +#pragma warning disable SA1005, S125 + Validate(await InvokeAndAssertAsync(options, plan, keepFunctionCallingMessages ? null : + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new TextContent("extra"), new TextContent("stuff")]), + new ChatMessage(ChatRole.Assistant, "more"), + new ChatMessage(ChatRole.Assistant, "world"), + ], configure)); + + Validate(await InvokeAndAssertStreamingAsync(options, plan, keepFunctionCallingMessages ? + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", "Func2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", "VoidReturn", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "extrastuffmoreworld"), + ] : + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, "extrastuffmoreworld"), + ], configure)); + + void Validate(List finalChat) { - Assert.All(content, c => Assert.False(c is FunctionCallContent or FunctionResultContent)); + IEnumerable content = finalChat.SelectMany(m => m.Contents); + if (keepFunctionCallingMessages) + { + Assert.Contains(content, c => c is FunctionCallContent or FunctionResultContent); + } + else + { + Assert.All(content, c => Assert.False(c is FunctionCallContent or FunctionResultContent)); + } } } @@ -267,12 +331,19 @@ public async Task ExceptionDetailsOnlyReportedWhenRequestedAsync(bool detailedEr ] }; - await InvokeAndAssertAsync(options, [ + List plan = + [ new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: detailedErrors ? "Error: Function failed. Exception: Oh no!" : "Error: Function failed.")]), new ChatMessage(ChatRole.Assistant, "world"), - ], configurePipeline: b => b.Use(s => new FunctionInvokingChatClient(s) { DetailedErrors = detailedErrors })); + ]; + + Func configure = b => b.Use(s => new FunctionInvokingChatClient(s) { DetailedErrors = detailedErrors }); + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); } [Fact] @@ -281,28 +352,36 @@ public async Task RejectsMultipleChoicesAsync() var func1 = AIFunctionFactory.Create(() => "Some result 1", "Func1"); var func2 = AIFunctionFactory.Create(() => "Some result 2", "Func2"); + var expected = new ChatCompletion( + [ + new(ChatRole.Assistant, [new FunctionCallContent("callId1", func1.Metadata.Name)]), + new(ChatRole.Assistant, [new FunctionCallContent("callId2", func2.Metadata.Name)]), + ]); + using var innerClient = new TestChatClient { CompleteAsyncCallback = async (chatContents, options, cancellationToken) => { await Task.Yield(); - - return new ChatCompletion( - [ - new(ChatRole.Assistant, [new FunctionCallContent("callId1", func1.Metadata.Name)]), - new(ChatRole.Assistant, [new FunctionCallContent("callId2", func2.Metadata.Name)]), - ]); - } + return expected; + }, + CompleteStreamingAsyncCallback = (chatContents, options, cancellationToken) => + YieldAsync(expected.ToStreamingChatCompletionUpdates()), }; IChatClient service = innerClient.AsBuilder().UseFunctionInvocation().Build(); List chat = [new ChatMessage(ChatRole.User, "hello")]; - var ex = await Assert.ThrowsAsync( - () => service.CompleteAsync(chat, new ChatOptions { Tools = [func1, func2] })); + ChatOptions options = new() { Tools = [func1, func2] }; - Assert.Contains("only accepts a single choice", ex.Message); - Assert.Single(chat); // It didn't add anything to the chat history + Validate(await Assert.ThrowsAsync(() => service.CompleteAsync(chat, options))); + Validate(await Assert.ThrowsAsync(() => service.CompleteStreamingAsync(chat, options).ToChatCompletionAsync())); + + void Validate(Exception ex) + { + Assert.Contains("only accepts a single choice", ex.Message); + Assert.Single(chat); // It didn't add anything to the chat history + } } [Theory] @@ -311,39 +390,51 @@ public async Task RejectsMultipleChoicesAsync() [InlineData(LogLevel.Information)] public async Task FunctionInvocationsLogged(LogLevel level) { - using CapturingLoggerProvider clp = new(); - - ServiceCollection c = new(); - c.AddLogging(b => b.AddProvider(clp).SetMinimumLevel(level)); - var services = c.BuildServiceProvider(); + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1", new Dictionary { ["arg1"] = "value1" })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")] }; - await InvokeAndAssertAsync(options, [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1", new Dictionary { ["arg1"] = "value1" })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]), - new ChatMessage(ChatRole.Assistant, "world"), - ], configurePipeline: b => b.Use(c => new FunctionInvokingChatClient(c, services.GetRequiredService>()))); + Func configure = b => + b.Use((c, services) => new FunctionInvokingChatClient(c, services.GetRequiredService>())); - if (level is LogLevel.Trace) - { - Assert.Collection(clp.Logger.Entries, - entry => Assert.True(entry.Message.Contains("Invoking Func1({") && entry.Message.Contains("\"arg1\": \"value1\"")), - entry => Assert.True(entry.Message.Contains("Func1 invocation completed. Duration:") && entry.Message.Contains("Result: \"Result 1\""))); - } - else if (level is LogLevel.Debug) - { - Assert.Collection(clp.Logger.Entries, - entry => Assert.True(entry.Message.Contains("Invoking Func1") && !entry.Message.Contains("arg1")), - entry => Assert.True(entry.Message.Contains("Func1 invocation completed. Duration:") && !entry.Message.Contains("Result"))); - } - else + await InvokeAsync(services => InvokeAndAssertAsync(options, plan, configurePipeline: configure, services: services)); + + await InvokeAsync(services => InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure, services: services)); + + async Task InvokeAsync(Func work) { - Assert.Empty(clp.Logger.Entries); + using CapturingLoggerProvider clp = new(); + + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(clp).SetMinimumLevel(level)); + + await work(c.BuildServiceProvider()); + + if (level is LogLevel.Trace) + { + Assert.Collection(clp.Logger.Entries, + entry => Assert.True(entry.Message.Contains("Invoking Func1({") && entry.Message.Contains("\"arg1\": \"value1\"")), + entry => Assert.True(entry.Message.Contains("Func1 invocation completed. Duration:") && entry.Message.Contains("Result: \"Result 1\""))); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(clp.Logger.Entries, + entry => Assert.True(entry.Message.Contains("Invoking Func1") && !entry.Message.Contains("arg1")), + entry => Assert.True(entry.Message.Contains("Func1 invocation completed. Duration:") && !entry.Message.Contains("Result"))); + } + else + { + Assert.Empty(clp.Logger.Entries); + } } } @@ -353,38 +444,51 @@ await InvokeAndAssertAsync(options, [ public async Task FunctionInvocationTrackedWithActivity(bool enableTelemetry) { string sourceName = Guid.NewGuid().ToString(); - var activities = new List(); - using TracerProvider? tracerProvider = enableTelemetry ? - OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddInMemoryExporter(activities) - .Build() : - null; - - var options = new ChatOptions - { - Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")] - }; - await InvokeAndAssertAsync(options, [ + List plan = + [ new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1", new Dictionary { ["arg1"] = "value1" })]), new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]), new ChatMessage(ChatRole.Assistant, "world"), - ], configurePipeline: b => b.Use(c => - new FunctionInvokingChatClient( - new OpenTelemetryChatClient(c, sourceName: sourceName)))); + ]; - if (enableTelemetry) + ChatOptions options = new() { - Assert.Collection(activities, - activity => Assert.Equal("chat", activity.DisplayName), - activity => Assert.Equal("Func1", activity.DisplayName), - activity => Assert.Equal("chat", activity.DisplayName)); - } - else + Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")] + }; + + Func configure = b => b.Use(c => + new FunctionInvokingChatClient( + new OpenTelemetryChatClient(c, sourceName: sourceName))); + + await InvokeAsync(() => InvokeAndAssertAsync(options, plan, configurePipeline: configure)); + + await InvokeAsync(() => InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure)); + + async Task InvokeAsync(Func work) { - Assert.Empty(activities); + var activities = new List(); + using TracerProvider? tracerProvider = enableTelemetry ? + OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build() : + null; + + await work(); + + if (enableTelemetry) + { + Assert.Collection(activities, + activity => Assert.Equal("chat", activity.DisplayName), + activity => Assert.Equal("Func1", activity.DisplayName), + activity => Assert.Equal("chat", activity.DisplayName)); + } + else + { + Assert.Empty(activities); + } } } @@ -392,7 +496,8 @@ private static async Task> InvokeAndAssertAsync( ChatOptions options, List plan, List? expected = null, - Func? configurePipeline = null) + Func? configurePipeline = null, + IServiceProvider? services = null) { Assert.NotEmpty(plan); @@ -400,7 +505,6 @@ private static async Task> InvokeAndAssertAsync( using CancellationTokenSource cts = new(); List chat = [plan[0]]; - int i = 0; using var innerClient = new TestChatClient { @@ -411,11 +515,11 @@ private static async Task> InvokeAndAssertAsync( await Task.Yield(); - return new ChatCompletion([plan[contents.Count]]); + return new ChatCompletion(new ChatMessage(ChatRole.Assistant, [.. plan[contents.Count].Contents])); } }; - IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(services); var result = await service.CompleteAsync(chat, options, cts.Token); chat.Add(result.Message); @@ -423,7 +527,7 @@ private static async Task> InvokeAndAssertAsync( expected ??= plan; Assert.NotNull(result); Assert.Equal(expected.Count, chat.Count); - for (; i < expected.Count; i++) + for (int i = 0; i < expected.Count; i++) { var expectedMessage = expected[i]; var chatMessage = chat[i]; @@ -456,4 +560,80 @@ private static async Task> InvokeAndAssertAsync( return chat; } + + private static async Task> InvokeAndAssertStreamingAsync( + ChatOptions options, + List plan, + List? expected = null, + Func? configurePipeline = null, + IServiceProvider? services = null) + { + Assert.NotEmpty(plan); + + configurePipeline ??= static b => b.UseFunctionInvocation(); + + using CancellationTokenSource cts = new(); + List chat = [plan[0]]; + + using var innerClient = new TestChatClient + { + CompleteStreamingAsyncCallback = (contents, actualOptions, actualCancellationToken) => + { + Assert.Same(chat, contents); + Assert.Equal(cts.Token, actualCancellationToken); + + return YieldAsync(new ChatCompletion(new ChatMessage(ChatRole.Assistant, [.. plan[contents.Count].Contents])).ToStreamingChatCompletionUpdates()); + } + }; + + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(services); + + var result = await service.CompleteStreamingAsync(chat, options, cts.Token).ToChatCompletionAsync(); + chat.Add(result.Message); + + expected ??= plan; + Assert.NotNull(result); + Assert.Equal(expected.Count, chat.Count); + for (int i = 0; i < expected.Count; i++) + { + var expectedMessage = expected[i]; + var chatMessage = chat[i]; + + Assert.Equal(expectedMessage.Role, chatMessage.Role); + Assert.Equal(expectedMessage.Text, chatMessage.Text); + Assert.Equal(expectedMessage.GetType(), chatMessage.GetType()); + + Assert.Equal(expectedMessage.Contents.Count, chatMessage.Contents.Count); + for (int j = 0; j < expectedMessage.Contents.Count; j++) + { + var expectedItem = expectedMessage.Contents[j]; + var chatItem = chatMessage.Contents[j]; + + Assert.Equal(expectedItem.GetType(), chatItem.GetType()); + Assert.Equal(expectedItem.ToString(), chatItem.ToString()); + if (expectedItem is FunctionCallContent expectedFunctionCall) + { + var chatFunctionCall = (FunctionCallContent)chatItem; + Assert.Equal(expectedFunctionCall.Name, chatFunctionCall.Name); + AssertExtensions.EqualFunctionCallParameters(expectedFunctionCall.Arguments, chatFunctionCall.Arguments); + } + else if (expectedItem is FunctionResultContent expectedFunctionResult) + { + var chatFunctionResult = (FunctionResultContent)chatItem; + AssertExtensions.EqualFunctionCallResults(expectedFunctionResult.Result, chatFunctionResult.Result); + } + } + } + + return chat; + } + + private static async IAsyncEnumerable YieldAsync(params T[] items) + { + await Task.Yield(); + foreach (var item in items) + { + yield return item; + } + } } From 042b4e6a4409434461e67c0d4ca1590d9984f4b2 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 20 Nov 2024 14:57:06 -0500 Subject: [PATCH 156/190] Change UseLogging to accept an ILoggerFactory instead of ILogger (#5682) Fits better with DI, and makes it consistent with UseFunctionInvocation and UseOpenTelemetry. --- .../LoggingChatClientBuilderExtensions.cs | 13 ++-- ...gingEmbeddingGeneratorBuilderExtensions.cs | 13 ++-- .../CapturingLogger.cs | 77 ------------------- ...ft.Extensions.AI.Abstractions.Tests.csproj | 1 + .../ChatClientIntegrationTests.cs | 56 +++++++++----- ...oft.Extensions.AI.Integration.Tests.csproj | 2 +- .../FunctionInvokingChatClientTests.cs | 12 +-- .../ChatCompletion/LoggingChatClientTests.cs | 24 +++--- .../LoggingEmbeddingGeneratorTests.cs | 12 +-- .../Microsoft.Extensions.AI.Tests.csproj | 1 - 10 files changed, 81 insertions(+), 130 deletions(-) delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/CapturingLogger.cs diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs index 508617ba708..61221af01a4 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs @@ -13,20 +13,23 @@ public static class LoggingChatClientBuilderExtensions { /// Adds logging to the chat client pipeline. /// The . - /// - /// An optional with which logging should be performed. If not supplied, an instance will be resolved from the service provider. + /// + /// An optional used to create a logger with which logging should be performed. + /// If not supplied, a required instance will be resolved from the service provider. /// /// An optional callback that can be used to configure the instance. /// The . public static ChatClientBuilder UseLogging( - this ChatClientBuilder builder, ILogger? logger = null, Action? configure = null) + this ChatClientBuilder builder, + ILoggerFactory? loggerFactory = null, + Action? configure = null) { _ = Throw.IfNull(builder); return builder.Use((innerClient, services) => { - logger ??= services.GetRequiredService().CreateLogger(nameof(LoggingChatClient)); - var chatClient = new LoggingChatClient(innerClient, logger); + loggerFactory ??= services.GetRequiredService(); + var chatClient = new LoggingChatClient(innerClient, loggerFactory.CreateLogger(typeof(LoggingChatClient))); configure?.Invoke(chatClient); return chatClient; }); diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs index a83c1885ec6..0ea85e7baaa 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs @@ -15,21 +15,24 @@ public static class LoggingEmbeddingGeneratorBuilderExtensions /// Specifies the type of the input passed to the generator. /// Specifies the type of the embedding instance produced by the generator. /// The . - /// - /// An optional with which logging should be performed. If not supplied, an instance will be resolved from the service provider. + /// + /// An optional used to create a logger with which logging should be performed. + /// If not supplied, a required instance will be resolved from the service provider. /// /// An optional callback that can be used to configure the instance. /// The . public static EmbeddingGeneratorBuilder UseLogging( - this EmbeddingGeneratorBuilder builder, ILogger? logger = null, Action>? configure = null) + this EmbeddingGeneratorBuilder builder, + ILoggerFactory? loggerFactory = null, + Action>? configure = null) where TEmbedding : Embedding { _ = Throw.IfNull(builder); return builder.Use((innerGenerator, services) => { - logger ??= services.GetRequiredService().CreateLogger(nameof(LoggingEmbeddingGenerator)); - var generator = new LoggingEmbeddingGenerator(innerGenerator, logger); + loggerFactory ??= services.GetRequiredService(); + var generator = new LoggingEmbeddingGenerator(innerGenerator, loggerFactory.CreateLogger(typeof(LoggingEmbeddingGenerator))); configure?.Invoke(generator); return generator; }); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/CapturingLogger.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/CapturingLogger.cs deleted file mode 100644 index 274021988e1..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/CapturingLogger.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.Collections.Generic; -using Microsoft.Extensions.Logging; - -#pragma warning disable SA1402 // File may only contain a single type - -namespace Microsoft.Extensions.AI; - -internal sealed class CapturingLogger : ILogger -{ - private readonly Stack _scopes = new(); - private readonly List _entries = []; - private readonly LogLevel _enabledLevel; - - public CapturingLogger(LogLevel enabledLevel = LogLevel.Trace) - { - _enabledLevel = enabledLevel; - } - - public IReadOnlyList Entries => _entries; - - public IDisposable? BeginScope(TState state) - where TState : notnull - { - var scope = new LoggerScope(this); - _scopes.Push(scope); - return scope; - } - - public bool IsEnabled(LogLevel logLevel) => logLevel >= _enabledLevel; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - if (!IsEnabled(logLevel)) - { - return; - } - - var message = formatter(state, exception); - lock (_entries) - { - _entries.Add(new LogEntry(logLevel, eventId, state, exception, message)); - } - } - - private sealed class LoggerScope(CapturingLogger owner) : IDisposable - { - public void Dispose() => owner.EndScope(this); - } - - private void EndScope(LoggerScope loggerScope) - { - if (_scopes.Peek() != loggerScope) - { - throw new InvalidOperationException("Logger scopes out of order"); - } - - _scopes.Pop(); - } - - public record LogEntry(LogLevel Level, EventId EventId, object? State, Exception? Exception, string Message); -} - -internal sealed class CapturingLoggerProvider : ILoggerProvider -{ - public CapturingLogger Logger { get; } = new(); - - public ILogger CreateLogger(string categoryName) => Logger; - - void IDisposable.Dispose() - { - // nop - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index 911ce1b2bf8..b22bdc9fdde 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index ab8e7613edb..818aba7a97b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -14,6 +14,8 @@ using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Microsoft.TestUtilities; using OpenTelemetry.Trace; using Xunit; @@ -498,14 +500,16 @@ public virtual async Task Logging_LogsCalls_NonStreaming() { SkipIfNotEnabled(); - CapturingLogger logger = new(); + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(LogLevel.Trace)); - using var chatClient = - new LoggingChatClient(CreateChatClient()!, logger); + using var chatClient = CreateChatClient()!.AsBuilder() + .UseLogging(loggerFactory) + .Build(); await chatClient.CompleteAsync([new(ChatRole.User, "What's the biggest animal?")]); - Assert.Collection(logger.Entries, + Assert.Collection(collector.GetSnapshot(), entry => Assert.Contains("What\\u0027s the biggest animal?", entry.Message), entry => Assert.Contains("whale", entry.Message)); } @@ -515,18 +519,21 @@ public virtual async Task Logging_LogsCalls_Streaming() { SkipIfNotEnabled(); - CapturingLogger logger = new(); + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(LogLevel.Trace)); - using var chatClient = - new LoggingChatClient(CreateChatClient()!, logger); + using var chatClient = CreateChatClient()!.AsBuilder() + .UseLogging(loggerFactory) + .Build(); await foreach (var update in chatClient.CompleteStreamingAsync("What's the biggest animal?")) { // Do nothing with the updates } - Assert.Contains(logger.Entries, e => e.Message.Contains("What\\u0027s the biggest animal?")); - Assert.Contains(logger.Entries, e => e.Message.Contains("whale")); + var logs = collector.GetSnapshot(); + Assert.Contains(logs, e => e.Message.Contains("What\\u0027s the biggest animal?")); + Assert.Contains(logs, e => e.Message.Contains("whale")); } [ConditionalFact] @@ -534,18 +541,21 @@ public virtual async Task Logging_LogsFunctionCalls_NonStreaming() { SkipIfNotEnabled(); - CapturingLogger logger = new(); + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(LogLevel.Trace)); - using var chatClient = - new FunctionInvokingChatClient( - new LoggingChatClient(CreateChatClient()!, logger)); + using var chatClient = CreateChatClient()! + .AsBuilder() + .UseFunctionInvocation() + .UseLogging(loggerFactory) + .Build(); int secretNumber = 42; await chatClient.CompleteAsync( "What is the current secret number?", new ChatOptions { Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")] }); - Assert.Collection(logger.Entries, + Assert.Collection(collector.GetSnapshot(), entry => Assert.Contains("What is the current secret number?", entry.Message), entry => Assert.Contains("\"name\": \"GetSecretNumber\"", entry.Message), entry => Assert.Contains($"\"result\": {secretNumber}", entry.Message), @@ -557,11 +567,14 @@ public virtual async Task Logging_LogsFunctionCalls_Streaming() { SkipIfNotEnabled(); - CapturingLogger logger = new(); + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(LogLevel.Trace)); - using var chatClient = - new FunctionInvokingChatClient( - new LoggingChatClient(CreateChatClient()!, logger)); + using var chatClient = CreateChatClient()! + .AsBuilder() + .UseFunctionInvocation() + .UseLogging(loggerFactory) + .Build(); int secretNumber = 42; await foreach (var update in chatClient.CompleteStreamingAsync( @@ -571,9 +584,10 @@ public virtual async Task Logging_LogsFunctionCalls_Streaming() // Do nothing with the updates } - Assert.Contains(logger.Entries, e => e.Message.Contains("What is the current secret number?")); - Assert.Contains(logger.Entries, e => e.Message.Contains("\"name\": \"GetSecretNumber\"")); - Assert.Contains(logger.Entries, e => e.Message.Contains($"\"result\": {secretNumber}")); + var logs = collector.GetSnapshot(); + Assert.Contains(logs, e => e.Message.Contains("What is the current secret number?")); + Assert.Contains(logs, e => e.Message.Contains("\"name\": \"GetSecretNumber\"")); + Assert.Contains(logs, e => e.Message.Contains($"\"result\": {secretNumber}")); } [ConditionalFact] 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 04d9bc6d29f..250c76e9d69 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 @@ -20,7 +20,6 @@ - @@ -36,6 +35,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index da983243acb..1dc91797037 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using OpenTelemetry.Trace; using Xunit; @@ -412,28 +413,29 @@ public async Task FunctionInvocationsLogged(LogLevel level) async Task InvokeAsync(Func work) { - using CapturingLoggerProvider clp = new(); + var collector = new FakeLogCollector(); ServiceCollection c = new(); - c.AddLogging(b => b.AddProvider(clp).SetMinimumLevel(level)); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); await work(c.BuildServiceProvider()); + var logs = collector.GetSnapshot(); if (level is LogLevel.Trace) { - Assert.Collection(clp.Logger.Entries, + Assert.Collection(logs, entry => Assert.True(entry.Message.Contains("Invoking Func1({") && entry.Message.Contains("\"arg1\": \"value1\"")), entry => Assert.True(entry.Message.Contains("Func1 invocation completed. Duration:") && entry.Message.Contains("Result: \"Result 1\""))); } else if (level is LogLevel.Debug) { - Assert.Collection(clp.Logger.Entries, + Assert.Collection(logs, entry => Assert.True(entry.Message.Contains("Invoking Func1") && !entry.Message.Contains("arg1")), entry => Assert.True(entry.Message.Contains("Func1 invocation completed. Duration:") && !entry.Message.Contains("Result"))); } else { - Assert.Empty(clp.Logger.Entries); + Assert.Empty(logs); } } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs index e07364b42c3..66abd7f6612 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using Xunit; namespace Microsoft.Extensions.AI; @@ -26,10 +27,10 @@ public void LoggingChatClient_InvalidArgs_Throws() [InlineData(LogLevel.Information)] public async Task CompleteAsync_LogsStartAndCompletion(LogLevel level) { - using CapturingLoggerProvider clp = new(); + var collector = new FakeLogCollector(); ServiceCollection c = new(); - c.AddLogging(b => b.AddProvider(clp).SetMinimumLevel(level)); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); var services = c.BuildServiceProvider(); using IChatClient innerClient = new TestChatClient @@ -49,21 +50,22 @@ await client.CompleteAsync( [new(ChatRole.User, "What's the biggest animal?")], new ChatOptions { FrequencyPenalty = 3.0f }); + var logs = collector.GetSnapshot(); if (level is LogLevel.Trace) { - Assert.Collection(clp.Logger.Entries, + Assert.Collection(logs, entry => Assert.True(entry.Message.Contains("CompleteAsync invoked:") && entry.Message.Contains("biggest animal")), entry => Assert.True(entry.Message.Contains("CompleteAsync completed:") && entry.Message.Contains("blue whale"))); } else if (level is LogLevel.Debug) { - Assert.Collection(clp.Logger.Entries, + Assert.Collection(logs, entry => Assert.True(entry.Message.Contains("CompleteAsync invoked.") && !entry.Message.Contains("biggest animal")), entry => Assert.True(entry.Message.Contains("CompleteAsync completed.") && !entry.Message.Contains("blue whale"))); } else { - Assert.Empty(clp.Logger.Entries); + Assert.Empty(logs); } } @@ -73,7 +75,8 @@ await client.CompleteAsync( [InlineData(LogLevel.Information)] public async Task CompleteStreamAsync_LogsStartUpdateCompletion(LogLevel level) { - CapturingLogger logger = new(level); + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); using IChatClient innerClient = new TestChatClient { @@ -89,7 +92,7 @@ static async IAsyncEnumerable GetUpdatesAsync() using IChatClient client = innerClient .AsBuilder() - .UseLogging(logger) + .UseLogging(loggerFactory) .Build(); await foreach (var update in client.CompleteStreamingAsync( @@ -99,9 +102,10 @@ static async IAsyncEnumerable GetUpdatesAsync() // nop } + var logs = collector.GetSnapshot(); if (level is LogLevel.Trace) { - Assert.Collection(logger.Entries, + Assert.Collection(logs, entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync invoked:") && entry.Message.Contains("biggest animal")), entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync received update:") && entry.Message.Contains("blue")), entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync received update:") && entry.Message.Contains("whale")), @@ -109,7 +113,7 @@ static async IAsyncEnumerable GetUpdatesAsync() } else if (level is LogLevel.Debug) { - Assert.Collection(logger.Entries, + Assert.Collection(logs, entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync invoked.") && !entry.Message.Contains("biggest animal")), entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync received update.") && !entry.Message.Contains("blue")), entry => Assert.True(entry.Message.Contains("CompleteStreamingAsync received update.") && !entry.Message.Contains("whale")), @@ -117,7 +121,7 @@ static async IAsyncEnumerable GetUpdatesAsync() } else { - Assert.Empty(logger.Entries); + Assert.Empty(logs); } } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs index ca5fa966ace..d4ab06a8667 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using Xunit; namespace Microsoft.Extensions.AI; @@ -25,10 +26,10 @@ public void LoggingEmbeddingGenerator_InvalidArgs_Throws() [InlineData(LogLevel.Information)] public async Task CompleteAsync_LogsStartAndCompletion(LogLevel level) { - using CapturingLoggerProvider clp = new(); + var collector = new FakeLogCollector(); ServiceCollection c = new(); - c.AddLogging(b => b.AddProvider(clp).SetMinimumLevel(level)); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); var services = c.BuildServiceProvider(); using IEmbeddingGenerator> innerGenerator = new TestEmbeddingGenerator @@ -46,21 +47,22 @@ public async Task CompleteAsync_LogsStartAndCompletion(LogLevel level) await generator.GenerateEmbeddingAsync("Blue whale"); + var logs = collector.GetSnapshot(); if (level is LogLevel.Trace) { - Assert.Collection(clp.Logger.Entries, + Assert.Collection(logs, entry => Assert.True(entry.Message.Contains("GenerateAsync invoked:") && entry.Message.Contains("Blue whale")), entry => Assert.Contains("GenerateAsync generated 1 embedding(s).", entry.Message)); } else if (level is LogLevel.Debug) { - Assert.Collection(clp.Logger.Entries, + Assert.Collection(logs, entry => Assert.True(entry.Message.Contains("GenerateAsync invoked.") && !entry.Message.Contains("Blue whale")), entry => Assert.Contains("GenerateAsync generated 1 embedding(s).", entry.Message)); } else { - Assert.Empty(clp.Logger.Entries); + Assert.Empty(logs); } } } 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 8675bdcf2f4..32589c430e0 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 @@ -17,7 +17,6 @@ - From f846de668b0903282395a407a3c66b66f670bab3 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 20 Nov 2024 21:46:16 +0000 Subject: [PATCH 157/190] Expose a schema transformer on AIJsonSchemaCreateOptions. (#5677) * Expose a schema transformer on AIJsonSchemaCreateOptions. * Address feedback * Disable caching if a transformer is specified. * Remove `FilterDisallowedKeywords`. * Document caching. * Apply suggestions from code review --- ...icrosoft.Extensions.AI.Abstractions.csproj | 1 + .../Utilities/AIJsonSchemaCreateContext.cs | 105 ++++++++++++++ .../Utilities/AIJsonSchemaCreateOptions.cs | 24 ++-- .../Utilities/AIJsonUtilities.Schema.cs | 130 +++++++++--------- .../Utilities/AIJsonUtilitiesTests.cs | 61 ++++---- .../Functions/AIFunctionFactoryTest.cs | 1 - 6 files changed, 215 insertions(+), 107 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index b96b4dca920..8b8541688ef 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -25,6 +25,7 @@ true true true + true diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs new file mode 100644 index 00000000000..22e3bc6066a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Reflection; +using System.Text.Json.Schema; +using System.Text.Json.Serialization.Metadata; + +#pragma warning disable CA1815 // Override equals and operator equals on value types + +namespace Microsoft.Extensions.AI; + +/// +/// Defines the context in which a JSON schema within a type graph is being generated. +/// +/// +/// This struct is being passed to the user-provided +/// callback by the method and cannot be instantiated directly. +/// +public readonly struct AIJsonSchemaCreateContext +{ + private readonly JsonSchemaExporterContext _exporterContext; + + internal AIJsonSchemaCreateContext(JsonSchemaExporterContext exporterContext) + { + _exporterContext = exporterContext; + } + + /// + /// Gets the path to the schema document currently being generated. + /// + public ReadOnlySpan Path => _exporterContext.Path; + + /// + /// Gets the for the type being processed. + /// + public JsonTypeInfo TypeInfo => _exporterContext.TypeInfo; + + /// + /// Gets the type info for the polymorphic base type if generated as a derived type. + /// + public JsonTypeInfo? BaseTypeInfo => _exporterContext.BaseTypeInfo; + + /// + /// Gets the if the schema is being generated for a property. + /// + public JsonPropertyInfo? PropertyInfo => _exporterContext.PropertyInfo; + + /// + /// Gets the declaring type of the property or parameter being processed. + /// + public Type? DeclaringType => +#if NET9_0_OR_GREATER + _exporterContext.PropertyInfo?.DeclaringType; +#else + _exporterContext.DeclaringType; +#endif + + /// + /// Gets the corresponding to the property or field being processed. + /// + public ICustomAttributeProvider? PropertyAttributeProvider => +#if NET9_0_OR_GREATER + _exporterContext.PropertyInfo?.AttributeProvider; +#else + _exporterContext.PropertyAttributeProvider; +#endif + + /// + /// Gets the of the + /// constructor parameter associated with the accompanying . + /// + public ICustomAttributeProvider? ParameterAttributeProvider => +#if NET9_0_OR_GREATER + _exporterContext.PropertyInfo?.AssociatedParameter?.AttributeProvider; +#else + _exporterContext.ParameterInfo; +#endif + + /// + /// Retrieves a custom attribute of a specified type that is applied to the specified schema node context. + /// + /// The type of attribute to search for. + /// If , specifies to also search the ancestors of the context members for custom attributes. + /// The first occurrence of if found, or otherwise. + /// + /// This helper method resolves attributes from context locations in the following order: + /// + /// Attributes specified on the property of the context, if specified. + /// Attributes specified on the constructor parameter of the context, if specified. + /// Attributes specified on the type of the context. + /// + /// + public TAttribute? GetCustomAttribute(bool inherit = false) + where TAttribute : Attribute + { + return GetCustomAttr(PropertyAttributeProvider) ?? + GetCustomAttr(ParameterAttributeProvider) ?? + GetCustomAttr(TypeInfo.Type); + + TAttribute? GetCustomAttr(ICustomAttributeProvider? provider) => + (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit).FirstOrDefault(); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs index 2ce42c3e618..ea1f393f7e5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Text.Json.Nodes; + namespace Microsoft.Extensions.AI; /// @@ -13,6 +16,11 @@ public sealed class AIJsonSchemaCreateOptions /// public static AIJsonSchemaCreateOptions Default { get; } = new AIJsonSchemaCreateOptions(); + /// + /// Gets a callback that is invoked for every schema that is generated within the type graph. + /// + public Func? TransformSchemaNode { get; init; } + /// /// Gets a value indicating whether to include the type keyword in inferred schemas for .NET enums. /// @@ -32,20 +40,4 @@ public sealed class AIJsonSchemaCreateOptions /// Gets a value indicating whether to mark all properties as required in the schema. /// public bool RequireAllProperties { get; init; } = true; - - /// - /// Gets a value indicating whether to filter keywords that are disallowed by certain AI vendors. - /// - /// - /// Filters a number of non-essential schema keywords that are not yet supported by some AI vendors. - /// These include: - /// - /// The "minLength", "maxLength", "pattern", and "format" keywords. - /// The "minimum", "maximum", and "multipleOf" keywords. - /// The "patternProperties", "unevaluatedProperties", "propertyNames", "minProperties", and "maxProperties" keywords. - /// The "unevaluatedItems", "contains", "minContains", "maxContains", "minItems", "maxItems", and "uniqueItems" keywords. - /// - /// See also https://platform.openai.com/docs/guides/structured-outputs#some-type-specific-keywords-are-not-yet-supported. - /// - public bool FilterDisallowedKeywords { get; init; } = true; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs index 4e3f90aa47f..01f3d23e4cb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.cs @@ -10,7 +10,6 @@ using System.Diagnostics.CodeAnalysis; #endif using System.Linq; -using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; @@ -23,18 +22,6 @@ #pragma warning disable S1075 // URIs should not be hardcoded #pragma warning disable SA1118 // Parameter should not span multiple lines -using FunctionParameterKey = ( - System.Type? Type, - string? ParameterName, - string? Description, - bool HasDefaultValue, - object? DefaultValue, - bool IncludeSchemaUri, - bool DisallowAdditionalProperties, - bool IncludeTypeInEnumSchemas, - bool RequireAllProperties, - bool FilterDisallowedKeywords); - namespace Microsoft.Extensions.AI; /// Provides a collection of utility methods for marshalling JSON data. @@ -47,7 +34,7 @@ public static partial class AIJsonUtilities private const int CacheSoftLimit = 4096; /// Caches of generated schemas for each that's employed. - private static readonly ConditionalWeakTable> _schemaCaches = new(); + private static readonly ConditionalWeakTable> _schemaCaches = new(); /// Gets a JSON schema accepting all values. private static readonly JsonElement _trueJsonSchema = ParseJsonElement("true"u8); @@ -107,6 +94,10 @@ public static JsonElement ResolveParameterJsonSchema( /// The options used to extract the schema from the specified type. /// The options controlling schema inference. /// A JSON schema document encoded as a . + /// + /// Uses a cache keyed on the to store schema result, + /// unless a delegate has been specified. + /// public static JsonElement CreateParameterJsonSchema( Type? type, string parameterName, @@ -121,17 +112,13 @@ public static JsonElement CreateParameterJsonSchema( serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; - FunctionParameterKey key = ( + SchemaGenerationKey key = new( type, parameterName, description, hasDefaultValue, defaultValue, - IncludeSchemaUri: false, - inferenceOptions.DisallowAdditionalProperties, - inferenceOptions.IncludeTypeInEnumSchemas, - inferenceOptions.RequireAllProperties, - inferenceOptions.FilterDisallowedKeywords); + inferenceOptions); return GetJsonSchemaCached(serializerOptions, key); } @@ -144,6 +131,10 @@ public static JsonElement CreateParameterJsonSchema( /// The options used to extract the schema from the specified type. /// The options controlling schema inference. /// A representing the schema. + /// + /// Uses a cache keyed on the to store schema result, + /// unless a delegate has been specified. + /// public static JsonElement CreateJsonSchema( Type? type, string? description = null, @@ -155,27 +146,23 @@ public static JsonElement CreateJsonSchema( serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; - FunctionParameterKey key = ( + SchemaGenerationKey key = new( type, - ParameterName: null, + parameterName: null, description, hasDefaultValue, defaultValue, - inferenceOptions.IncludeSchemaKeyword, - inferenceOptions.DisallowAdditionalProperties, - inferenceOptions.IncludeTypeInEnumSchemas, - inferenceOptions.RequireAllProperties, - inferenceOptions.FilterDisallowedKeywords); + inferenceOptions); return GetJsonSchemaCached(serializerOptions, key); } - private static JsonElement GetJsonSchemaCached(JsonSerializerOptions options, FunctionParameterKey key) + private static JsonElement GetJsonSchemaCached(JsonSerializerOptions options, SchemaGenerationKey key) { options.MakeReadOnly(); - ConcurrentDictionary cache = _schemaCaches.GetOrCreateValue(options); + ConcurrentDictionary cache = _schemaCaches.GetOrCreateValue(options); - if (cache.Count >= CacheSoftLimit) + if (key.TransformSchemaNode is not null || cache.Count >= CacheSoftLimit) { return GetJsonSchemaCore(options, key); } @@ -195,7 +182,7 @@ private static JsonElement GetJsonSchemaCached(JsonSerializerOptions options, Fu Justification = "Pre STJ-9 schema extraction can fail with a runtime exception if certain reflection metadata have been trimmed. " + "The exception message will guide users to turn off 'IlcTrimMetadata' which resolves all issues.")] #endif - private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, FunctionParameterKey key) + private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, SchemaGenerationKey key) { _ = Throw.IfNull(options); options.MakeReadOnly(); @@ -206,7 +193,7 @@ private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, Func JsonObject? schemaObj = null; - if (key.IncludeSchemaUri) + if (key.IncludeSchemaKeyword) { (schemaObj = [])["$schema"] = SchemaKeywordUri; } @@ -244,7 +231,7 @@ private static JsonElement GetJsonSchemaCore(JsonSerializerOptions options, Func JsonNode node = options.GetJsonSchemaAsNode(key.Type, exporterOptions); return JsonSerializer.SerializeToElement(node, JsonContext.Default.JsonNode); - JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) + JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, JsonNode schema) { const string SchemaPropertyName = "$schema"; const string DescriptionPropertyName = "description"; @@ -258,7 +245,9 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) const string DefaultPropertyName = "default"; const string RefPropertyName = "$ref"; - if (ctx.ResolveAttribute() is { } attr) + AIJsonSchemaCreateContext ctx = new(schemaExporterContext); + + if (ctx.GetCustomAttribute() is { } attr) { ConvertSchemaToObject(ref schema).InsertAtStart(DescriptionPropertyName, (JsonNode)attr.Description); } @@ -308,12 +297,9 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) } // Filter potentially disallowed keywords. - if (key.FilterDisallowedKeywords) + foreach (string keyword in _schemaKeywordsDisallowedByAIVendors) { - foreach (string keyword in _schemaKeywordsDisallowedByAIVendors) - { - _ = objSchema.Remove(keyword); - } + _ = objSchema.Remove(keyword); } // Some consumers of the JSON schema, including Ollama as of v0.3.13, don't understand @@ -357,13 +343,19 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext ctx, JsonNode schema) ConvertSchemaToObject(ref schema)[DefaultPropertyName] = defaultValue; } - if (key.IncludeSchemaUri) + if (key.IncludeSchemaKeyword) { // The $schema property must be the first keyword in the object ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); } } + // Finally, apply any user-defined transformations if specified. + if (key.TransformSchemaNode is { } transformer) + { + schema = transformer(ctx, schema); + } + return schema; static JsonObject ConvertSchemaToObject(ref JsonNode schema) @@ -388,7 +380,7 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) } } - private static bool TypeIsIntegerWithStringNumberHandling(JsonSchemaExporterContext ctx, JsonObject schema) + private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateContext ctx, JsonObject schema) { if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray typeArray) { @@ -443,30 +435,44 @@ private static int IndexOf(this JsonObject jsonObject, string key) } #endif - 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 -#if NET9_0_OR_GREATER - GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? - GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? -#else - GetAttrs(ctx.PropertyAttributeProvider) ?? - GetAttrs(ctx.ParameterInfo) ?? -#endif - GetAttrs(ctx.TypeInfo.Type); - - static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => - (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); - } - private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) { Utf8JsonReader reader = new(utf8Json); return JsonElement.ParseValue(ref reader); } + + /// The equatable key used to look up cached schemas. + private readonly record struct SchemaGenerationKey + { + public SchemaGenerationKey( + Type? type, + string? parameterName, + string? description, + bool hasDefaultValue, + object? defaultValue, + AIJsonSchemaCreateOptions options) + { + Type = type; + ParameterName = parameterName; + Description = description; + HasDefaultValue = hasDefaultValue; + DefaultValue = defaultValue; + IncludeSchemaKeyword = options.IncludeSchemaKeyword; + DisallowAdditionalProperties = options.DisallowAdditionalProperties; + IncludeTypeInEnumSchemas = options.IncludeTypeInEnumSchemas; + RequireAllProperties = options.RequireAllProperties; + TransformSchemaNode = options.TransformSchemaNode; + } + + public Type? Type { get; } + public string? ParameterName { get; } + public string? Description { get; } + public bool HasDefaultValue { get; } + public object? DefaultValue { get; } + public bool IncludeSchemaKeyword { get; } + public bool DisallowAdditionalProperties { get; } + public bool IncludeTypeInEnumSchemas { get; } + public bool RequireAllProperties { get; } + public Func? TransformSchemaNode { get; } + } } 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 4107618d85b..fb8501909cc 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -45,7 +45,7 @@ public static void AIJsonSchemaCreateOptions_DefaultInstance_ReturnsExpectedValu Assert.True(options.DisallowAdditionalProperties); Assert.False(options.IncludeSchemaKeyword); Assert.True(options.RequireAllProperties); - Assert.True(options.FilterDisallowedKeywords); + Assert.Null(options.TransformSchemaNode); } [Fact] @@ -125,53 +125,66 @@ public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSc } [Fact] - public static void CreateJsonSchema_FiltersDisallowedKeywords() + public static void CreateJsonSchema_UserDefinedTransformer() { JsonElement expected = JsonDocument.Parse(""" { + "description": "The type", "type": "object", "properties": { - "Date": { - "type": "string" + "Key": { + "$comment": "Contains a DescriptionAttribute declaration with the text 'The parameter'.", + "type": "integer" }, - "TimeSpan": { - "$comment": "Represents a System.TimeSpan value.", - "type": "string" + "EnumValue": { + "type": "string", + "enum": ["A", "B"] }, - "Char" : { - "type": "string" + "Value": { + "type": ["string", "null"], + "default": null } }, - "required": ["Date","TimeSpan","Char"], + "required": ["Key", "EnumValue", "Value"], "additionalProperties": false } """).RootElement; - JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords), serializerOptions: JsonSerializerOptions.Default); + AIJsonSchemaCreateOptions inferenceOptions = new() + { + TransformSchemaNode = static (context, schema) => + { + return context.TypeInfo.Type == typeof(int) && context.GetCustomAttribute() is DescriptionAttribute attr + ? new JsonObject + { + ["$comment"] = $"Contains a DescriptionAttribute declaration with the text '{attr.Description}'.", + ["type"] = "integer", + } + : schema; + } + }; + + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco), serializerOptions: JsonSerializerOptions.Default, inferenceOptions: inferenceOptions); Assert.True(JsonElement.DeepEquals(expected, actual)); } [Fact] - public static void CreateJsonSchema_FilterDisallowedKeywords_Disabled() + public static void CreateJsonSchema_FiltersDisallowedKeywords() { JsonElement expected = JsonDocument.Parse(""" { "type": "object", "properties": { "Date": { - "type": "string", - "format": "date-time" + "type": "string" }, "TimeSpan": { "$comment": "Represents a System.TimeSpan value.", - "type": "string", - "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + "type": "string" }, "Char" : { - "type": "string", - "minLength": 1, - "maxLength": 1 + "type": "string" } }, "required": ["Date","TimeSpan","Char"], @@ -179,15 +192,7 @@ public static void CreateJsonSchema_FilterDisallowedKeywords_Disabled() } """).RootElement; - AIJsonSchemaCreateOptions inferenceOptions = new() - { - FilterDisallowedKeywords = false - }; - - JsonElement actual = AIJsonUtilities.CreateJsonSchema( - typeof(PocoWithTypesWithOpenAIUnsupportedKeywords), - serializerOptions: JsonSerializerOptions.Default, - inferenceOptions: inferenceOptions); + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords), serializerOptions: JsonSerializerOptions.Default); Assert.True(JsonElement.DeepEquals(expected, actual)); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index c72a2f3082f..207a4705751 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -191,7 +191,6 @@ public void AIFunctionFactoryCreateOptions_SchemaOptions_HasExpectedDefaults() Assert.NotNull(schemaOptions); Assert.True(schemaOptions.IncludeTypeInEnumSchemas); - Assert.True(schemaOptions.FilterDisallowedKeywords); Assert.True(schemaOptions.RequireAllProperties); Assert.True(schemaOptions.DisallowAdditionalProperties); } From d65645cc38d59fb13a03c2d420645559a46b253a Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 Nov 2024 19:21:15 -0500 Subject: [PATCH 158/190] Update M.E.AI CHANGELOG.md for latest bits (#5684) --- .../CHANGELOG.md | 12 ++++++++++++ .../CHANGELOG.md | 4 ++++ .../Microsoft.Extensions.AI.Ollama/CHANGELOG.md | 4 ++++ .../Microsoft.Extensions.AI.OpenAI/CHANGELOG.md | 6 ++++++ .../Microsoft.Extensions.AI/CHANGELOG.md | 16 ++++++++++++++++ 5 files changed, 42 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md index 1421517957b..a250548fd0c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -1,5 +1,17 @@ # Release History +## 9.0.1-preview.1.24570.5 + +- Changed `IChatClient`/`IEmbeddingGenerator`.`GetService` to be non-generic. +- Added `ToChatCompletion` / `ToChatCompletionUpdate` extension methods for `IEnumerable` / `IAsyncEnumerable`, respectively. +- Added `ToStreamingChatCompletionUpdates` instance method to `ChatCompletion`. +- Added `IncludeTypeInEnumSchemas`, `DisallowAdditionalProperties`, `RequireAllProperties`, and `TransformSchemaNode` options to `AIJsonSchemaCreateOptions`. +- Fixed a Native AOT warning in `AIFunctionFactory.Create`. +- Fixed a bug in `AIJsonUtilities` in the handling of Boolean schemas. +- Improved the `ToString` override of `ChatMessage` and `StreamingChatCompletionUpdate` to include all `TextContent`, and of `ChatCompletion` to include all choices. +- Added `DebuggerDisplay` attributes to `DataContent` and `GeneratedEmbeddings`. +- Improved the documentation. + ## 9.0.0-preview.9.24556.5 - Added a strongly-typed `ChatOptions.Seed` property. diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md index b094d59853f..ddba003d62f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## 9.0.1-preview.1.24570.5 + + - Made the `ToolCallJsonSerializerOptions` property non-nullable. + ## 9.0.0-preview.9.24556.5 - Fixed `AzureAIInferenceEmbeddingGenerator` to respect `EmbeddingGenerationOptions.Dimensions`. diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md index ffb35814039..37199883e66 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## 9.0.1-preview.1.24570.5 + + - Made the `ToolCallJsonSerializerOptions` property non-nullable. + ## 9.0.0-preview.9.24525.1 - Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index 179da41a0b0..a6378c55d86 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 9.0.1-preview.1.24570.5 + + - Upgraded to depend on the 2.1.0-beta.2 version of the OpenAI NuGet package. + - Added the `OpenAIRealtimeExtensions` class, with `ToConversationFunctionTool` and `HandleToolCallsAsync` extension methods for using `AIFunction` with the OpenAI Realtime API. + - Made the `ToolCallJsonSerializerOptions` property non-nullable. + ## 9.0.0-preview.9.24525.1 - Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. diff --git a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md index a84e0a00909..0d188e282d3 100644 --- a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md @@ -1,5 +1,21 @@ # Release History +## 9.0.1-preview.1.24570.5 + +- Moved the `AddChatClient`, `AddKeyedChatClient`, `AddEmbeddingGenerator`, and `AddKeyedEmbeddingGenerator` extension methods to the `Microsoft.Extensions.DependencyInjection` namespace, changed them to register singleton instances instead of scoped instances, and changed them to support lambda-less chaining. +- Renamed `UseChatOptions`/`UseEmbeddingOptions` to `ConfigureOptions`, and changed the behavior to always invoke the delegate with a safely-mutable instance, either a new instance if the caller provided null, or a clone of the provided instance. +- Renamed the final `Use` method for building a builder to be named `Build`. The inner client instance is passed to the constructor and the `IServiceProvider` is optionally passed to the `Build` method. +- Added `AsBuilder` extension methods to `IChatClient`/`IEmbeddingGenerator` to create builders from the instances. +- Changed the `CachingChatClient`/`CachingEmbeddingGenerator`.`GetCacheKey` method to accept a `params ReadOnlySpan`, included the `ChatOptions`/`EmbeddingGeneratorOptions` as part of the caching key, and reduced memory allocation. +- Added support for anonymous delegating `IChatClient`/`IEmbeddingGenerator` implementations, with `Use` methods on `ChatClientBuilder`/`EmbeddingGeneratorBuilder` that enable the implementations of the core methods to be supplied as lambdas. +- Changed `UseLogging` to accept an `ILoggerFactory` rather than `ILogger`. +- Reversed the order of the `IChatClient`/`IEmbeddingGenerator` and `IServiceProvider` arguments to used by one of the `Use` overloads. +- Added logging capabilities to `FunctionInvokingChatClient`. `UseFunctionInvocation` now accepts an optional `ILoggerFactory`. +- Fixed the `FunctionInvokingChatClient` to include usage data for non-streaming completions in the augmented history. +- Fixed the `FunctionInvokingChatClient` streaming support to appropriately fail for multi-choice completions. +- Fixed the `FunctionInvokingChatClient` to stop yielding function calling content that was already being handled. +- Improved the documentation. + ## 9.0.0-preview.9.24556.5 - Added `UseEmbeddingGenerationOptions` and corresponding `ConfigureOptionsEmbeddingGenerator`. From c08e5ac737d0830ff83c1c5ae8b084e6fce2b538 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:03:27 -0800 Subject: [PATCH 159/190] Update dependencies from https://github.com/dotnet/aspnetcore build 20241119.7 (#5678) Microsoft.AspNetCore.App.Ref , Microsoft.AspNetCore.App.Runtime.win-x64 , Microsoft.AspNetCore.Mvc.Testing , Microsoft.AspNetCore.TestHost , Microsoft.Extensions.Caching.SqlServer , Microsoft.Extensions.Caching.StackExchangeRedis , Microsoft.Extensions.Diagnostics.HealthChecks , Microsoft.Extensions.Http.Polly , Microsoft.Extensions.ObjectPool From Version 9.0.1 -> To Version 9.0.1 Co-authored-by: dotnet-maestro[bot] Co-authored-by: Safia Abdalla --- eng/Version.Details.xml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 93f55e662b3..b494b7048e9 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -150,39 +150,39 @@ https://github.com/dotnet/aspnetcore - be19faf14ebebb49e22deec138db0133990cf3ef + 0a5f4deafc371a78e89eea7bfaa615404c52cd6a https://github.com/dotnet/aspnetcore - be19faf14ebebb49e22deec138db0133990cf3ef + 0a5f4deafc371a78e89eea7bfaa615404c52cd6a https://github.com/dotnet/aspnetcore - be19faf14ebebb49e22deec138db0133990cf3ef + 0a5f4deafc371a78e89eea7bfaa615404c52cd6a https://github.com/dotnet/aspnetcore - be19faf14ebebb49e22deec138db0133990cf3ef + 0a5f4deafc371a78e89eea7bfaa615404c52cd6a https://github.com/dotnet/aspnetcore - be19faf14ebebb49e22deec138db0133990cf3ef + 0a5f4deafc371a78e89eea7bfaa615404c52cd6a https://github.com/dotnet/aspnetcore - be19faf14ebebb49e22deec138db0133990cf3ef + 0a5f4deafc371a78e89eea7bfaa615404c52cd6a https://github.com/dotnet/aspnetcore - be19faf14ebebb49e22deec138db0133990cf3ef + 0a5f4deafc371a78e89eea7bfaa615404c52cd6a https://github.com/dotnet/aspnetcore - be19faf14ebebb49e22deec138db0133990cf3ef + 0a5f4deafc371a78e89eea7bfaa615404c52cd6a https://github.com/dotnet/aspnetcore - be19faf14ebebb49e22deec138db0133990cf3ef + 0a5f4deafc371a78e89eea7bfaa615404c52cd6a From cfed375f3161f2e553e946b4f968b818e8e858f1 Mon Sep 17 00:00:00 2001 From: Iliar Turdushev Date: Mon, 25 Nov 2024 10:56:29 +0100 Subject: [PATCH 160/190] Add API allowing to disable retries for a given list of HTTP methods (#5634) * Fixes #5248 Adds APIs allowing to disable automatic retries for a given list of HTTP methods * Fixes #5248 Adds a check ensuring options.ShouldHandle is not null --- .../HttpRetryStrategyOptionsExtensions.cs | 66 ++++++++++ ...HttpRetryStrategyOptionsExtensionsTests.cs | 115 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs create mode 100644 test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs new file mode 100644 index 00000000000..85168988c7b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using Polly; + +namespace Microsoft.Extensions.Http.Resilience; + +/// +/// Extensions for . +/// +[Experimental(diagnosticId: DiagnosticIds.Experiments.Resilience, UrlFormat = DiagnosticIds.UrlFormat)] +public static class HttpRetryStrategyOptionsExtensions +{ +#if !NET8_0_OR_GREATER + private static readonly HttpMethod _connect = new("CONNECT"); + private static readonly HttpMethod _patch = new("PATCH"); +#endif + + /// + /// Disables retry attempts for POST, PATCH, PUT, DELETE, and CONNECT HTTP methods. + /// + /// The retry strategy options. + public static void DisableForUnsafeHttpMethods(this HttpRetryStrategyOptions options) + { + options.DisableFor( + HttpMethod.Delete, HttpMethod.Post, HttpMethod.Put, +#if !NET8_0_OR_GREATER + _connect, _patch); +#else + HttpMethod.Connect, HttpMethod.Patch); +#endif + } + + /// + /// Disables retry attempts for the given list of HTTP methods. + /// + /// The retry strategy options. + /// The list of HTTP methods. + public static void DisableFor(this HttpRetryStrategyOptions options, params HttpMethod[] methods) + { + _ = Throw.IfNullOrEmpty(methods); + + var shouldHandle = Throw.IfNullOrMemberNull(options, options?.ShouldHandle); + + options.ShouldHandle = async args => + { + var result = await shouldHandle(args).ConfigureAwait(args.Context.ContinueOnCapturedContext); + + if (result && + args.Outcome.Result is HttpResponseMessage response && + response.RequestMessage is HttpRequestMessage request) + { + return !methods.Contains(request.Method); + } + + return result; + }; + } +} + diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs new file mode 100644 index 00000000000..4d43c020d1f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs @@ -0,0 +1,115 @@ +// 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 Polly; +using Polly.Retry; +using Xunit; + +namespace Microsoft.Extensions.Http.Resilience.Test.Polly; + +public class HttpRetryStrategyOptionsExtensionsTests +{ + [Fact] + public void DisableFor_RetryOptionsIsNull_Throws() + { + Assert.Throws(() => ((HttpRetryStrategyOptions)null!).DisableFor(HttpMethod.Get)); + } + + [Fact] + public void DisableFor_HttpMethodsIsNull_Throws() + { + Assert.Throws(() => new HttpRetryStrategyOptions().DisableFor(null!)); + } + + [Fact] + public void DisableFor_HttpMethodsIsEmptry_Throws() + { + Assert.Throws(() => new HttpRetryStrategyOptions().DisableFor([])); + } + + [Fact] + public void DisableFor_ShouldHandleIsNull_Throws() + { + var options = new HttpRetryStrategyOptions { ShouldHandle = null! }; + Assert.Throws(() => options.DisableFor(HttpMethod.Get)); + } + + [Theory] + [InlineData("POST", false)] + [InlineData("DELETE", false)] + [InlineData("GET", true)] + public async Task DisableFor_PositiveScenario(string httpMethod, bool shouldHandle) + { + var options = new HttpRetryStrategyOptions { ShouldHandle = _ => PredicateResult.True() }; + options.DisableFor(HttpMethod.Post, HttpMethod.Delete); + + using var request = new HttpRequestMessage { Method = new HttpMethod(httpMethod) }; + using var response = new HttpResponseMessage { RequestMessage = request }; + + Assert.Equal(shouldHandle, await options.ShouldHandle(CreatePredicateArguments(response))); + } + + [Fact] + public async Task DisableFor_RespectsOriginalShouldHandlePredicate() + { + var options = new HttpRetryStrategyOptions { ShouldHandle = _ => PredicateResult.False() }; + options.DisableFor(HttpMethod.Post); + + using var request = new HttpRequestMessage { Method = HttpMethod.Get }; + using var response = new HttpResponseMessage { RequestMessage = request }; + + Assert.False(await options.ShouldHandle(CreatePredicateArguments(response))); + } + + [Fact] + public async Task DisableFor_ResponseMessageIsNull_DoesNotDisableRetries() + { + var options = new HttpRetryStrategyOptions { ShouldHandle = _ => PredicateResult.True() }; + options.DisableFor(HttpMethod.Post); + + Assert.True(await options.ShouldHandle(CreatePredicateArguments(null))); + } + + [Fact] + public async Task DisableFor_RequestMessageIsNull_DoesNotDisableRetries() + { + var options = new HttpRetryStrategyOptions { ShouldHandle = _ => PredicateResult.True() }; + options.DisableFor(HttpMethod.Post); + + using var response = new HttpResponseMessage { RequestMessage = null }; + + Assert.True(await options.ShouldHandle(CreatePredicateArguments(response))); + } + + [Theory] + [InlineData("POST", false)] + [InlineData("DELETE", false)] + [InlineData("PUT", false)] + [InlineData("PATCH", false)] + [InlineData("CONNECT", false)] + [InlineData("GET", true)] + [InlineData("HEAD", true)] + [InlineData("TRACE", true)] + [InlineData("OPTIONS", true)] + public async Task DisableForUnsafeHttpMethods_PositiveScenario(string httpMethod, bool shouldHandle) + { + var options = new HttpRetryStrategyOptions { ShouldHandle = _ => PredicateResult.True() }; + options.DisableForUnsafeHttpMethods(); + + using var request = new HttpRequestMessage { Method = new HttpMethod(httpMethod) }; + using var response = new HttpResponseMessage { RequestMessage = request }; + + Assert.Equal(shouldHandle, await options.ShouldHandle(CreatePredicateArguments(response))); + } + + private static RetryPredicateArguments CreatePredicateArguments(HttpResponseMessage? response) + { + return new RetryPredicateArguments( + ResilienceContextPool.Shared.Get(), + Outcome.FromResult(response), + attemptNumber: 1); + } +} From a5774e4518b17799167a8648d9bb34a136ffbab1 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:42:09 +0000 Subject: [PATCH 161/190] Update dependencies from https://github.com/dotnet/arcade build 20241122.2 (#5693) [main] Update dependencies from dotnet/arcade --- eng/Version.Details.xml | 8 ++++---- eng/common/sdk-task.ps1 | 2 +- eng/common/tools.ps1 | 4 ++-- global.json | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index b494b7048e9..2b2a75ec2df 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -186,13 +186,13 @@ - + https://github.com/dotnet/arcade - 1c7e09a8d9c9c9b15ba574cd6a496553505559de + b41381d5cd633471265e9cd72e933a7048e03062 - + https://github.com/dotnet/arcade - 1c7e09a8d9c9c9b15ba574cd6a496553505559de + b41381d5cd633471265e9cd72e933a7048e03062 diff --git a/eng/common/sdk-task.ps1 b/eng/common/sdk-task.ps1 index aab40de3fd9..4f0546dce12 100644 --- a/eng/common/sdk-task.ps1 +++ b/eng/common/sdk-task.ps1 @@ -64,7 +64,7 @@ try { $GlobalJson.tools | Add-Member -Name "vs" -Value (ConvertFrom-Json "{ `"version`": `"16.5`" }") -MemberType NoteProperty } if( -not ($GlobalJson.tools.PSObject.Properties.Name -match "xcopy-msbuild" )) { - $GlobalJson.tools | Add-Member -Name "xcopy-msbuild" -Value "17.10.0-pre.4.0" -MemberType NoteProperty + $GlobalJson.tools | Add-Member -Name "xcopy-msbuild" -Value "17.12.0" -MemberType NoteProperty } if ($GlobalJson.tools."xcopy-msbuild".Trim() -ine "none") { $xcopyMSBuildToolsFolder = InitializeXCopyMSBuild $GlobalJson.tools."xcopy-msbuild" -install $true diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index 22954477a57..aa94fb17459 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -383,8 +383,8 @@ function InitializeVisualStudioMSBuild([bool]$install, [object]$vsRequirements = # If the version of msbuild is going to be xcopied, # use this version. Version matches a package here: - # https://dev.azure.com/dnceng/public/_artifacts/feed/dotnet-eng/NuGet/Microsoft.DotNet.Arcade.MSBuild.Xcopy/versions/17.10.0-pre.4.0 - $defaultXCopyMSBuildVersion = '17.10.0-pre.4.0' + # https://dev.azure.com/dnceng/public/_artifacts/feed/dotnet-eng/NuGet/Microsoft.DotNet.Arcade.MSBuild.Xcopy/versions/17.12.0 + $defaultXCopyMSBuildVersion = '17.12.0' if (!$vsRequirements) { if (Get-Member -InputObject $GlobalJson.tools -Name 'vs') { diff --git a/global.json b/global.json index 3778d7cce2c..464bad9c97d 100644 --- a/global.json +++ b/global.json @@ -1,9 +1,9 @@ { "sdk": { - "version": "9.0.100-rtm.24479.2" + "version": "9.0.100" }, "tools": { - "dotnet": "9.0.100-rtm.24479.2", + "dotnet": "9.0.100", "runtimes": { "dotnet": [ "8.0.0", @@ -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.24562.13", - "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.24562.13" + "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.24572.2", + "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.24572.2" } } From a7e1413e4ca0c327bd2e0e43cd575b1473e10d39 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 26 Nov 2024 18:58:24 -0500 Subject: [PATCH 162/190] Update M.E.AI code coverage mins from 0 (#5698) --- .../Microsoft.Extensions.AI.Abstractions.csproj | 2 +- .../Microsoft.Extensions.AI.AzureAIInference.csproj | 2 +- .../Microsoft.Extensions.AI.Ollama.csproj | 2 +- .../Microsoft.Extensions.AI.OpenAI.csproj | 2 +- .../Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index 8b8541688ef..4d7e314a0e4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -9,7 +9,7 @@ preview true - 0 + 83 0 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 0e3f60b8db3..0c1f162542b 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 @@ -9,7 +9,7 @@ preview true - 0 + 77 0 diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj index 018184d6bf0..f80630ceeb5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj @@ -9,7 +9,7 @@ preview true - 0 + 80 0 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 1d400389af0..43991fa84e6 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 @@ -9,7 +9,7 @@ preview true - 0 + 66 0 diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 33628f2562a..a3bed483c44 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -11,7 +11,7 @@ preview true - 0 + 83 0 From eace8d6bd03997f1f6a271547b03cf4a44b23657 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Wed, 27 Nov 2024 14:18:53 +0000 Subject: [PATCH 163/190] Update dependencies from https://github.com/dotnet/aspnetcore build 20241126.21 (#5702) [main] Update dependencies from dotnet/aspnetcore --- NuGet.config | 5 +++++ eng/Version.Details.xml | 18 +++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/NuGet.config b/NuGet.config index 549cc5b7ead..a724678cd40 100644 --- a/NuGet.config +++ b/NuGet.config @@ -2,6 +2,11 @@ + + + + + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 2b2a75ec2df..2582655ecdf 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -150,39 +150,39 @@ https://github.com/dotnet/aspnetcore - 0a5f4deafc371a78e89eea7bfaa615404c52cd6a + 97de658c5eb540a63d85941a7678fd4bc9db5d37 https://github.com/dotnet/aspnetcore - 0a5f4deafc371a78e89eea7bfaa615404c52cd6a + 97de658c5eb540a63d85941a7678fd4bc9db5d37 https://github.com/dotnet/aspnetcore - 0a5f4deafc371a78e89eea7bfaa615404c52cd6a + 97de658c5eb540a63d85941a7678fd4bc9db5d37 https://github.com/dotnet/aspnetcore - 0a5f4deafc371a78e89eea7bfaa615404c52cd6a + 97de658c5eb540a63d85941a7678fd4bc9db5d37 https://github.com/dotnet/aspnetcore - 0a5f4deafc371a78e89eea7bfaa615404c52cd6a + 97de658c5eb540a63d85941a7678fd4bc9db5d37 https://github.com/dotnet/aspnetcore - 0a5f4deafc371a78e89eea7bfaa615404c52cd6a + 97de658c5eb540a63d85941a7678fd4bc9db5d37 https://github.com/dotnet/aspnetcore - 0a5f4deafc371a78e89eea7bfaa615404c52cd6a + 97de658c5eb540a63d85941a7678fd4bc9db5d37 https://github.com/dotnet/aspnetcore - 0a5f4deafc371a78e89eea7bfaa615404c52cd6a + 97de658c5eb540a63d85941a7678fd4bc9db5d37 https://github.com/dotnet/aspnetcore - 0a5f4deafc371a78e89eea7bfaa615404c52cd6a + 97de658c5eb540a63d85941a7678fd4bc9db5d37 From 544d905350613c9ae3f8ff48563a8f075a210951 Mon Sep 17 00:00:00 2001 From: Amadeusz Lechniak Date: Thu, 28 Nov 2024 13:28:08 +0100 Subject: [PATCH 164/190] Improve FakeTimeProvider documentation, remove redundant tests (#5683) * Improve FakeTimerProvider documentation, remove redundant tests, refactor tests methods * Refactor --- .../README.md | 104 +++++++++++++++--- .../FakeTimeProviderTests.cs | 83 +++++++++----- .../TimerTests.cs | 60 +++------- 3 files changed, 162 insertions(+), 85 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/README.md b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/README.md index f8faa6fdf2e..2984772d6d1 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/README.md +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/README.md @@ -33,31 +33,109 @@ public void Advance(TimeSpan delta) public void SetLocalTimeZone(TimeZoneInfo localTimeZone) ``` -These can be used as follows: +### `ExpiryCache` with `TimeProvider` + +The example below demonstrates the `ExpiryCache` class and how it can be tested using `FakeTimeProvider` in `ExpiryCacheTests`. + +The `TimeProvider` abstraction is injected into the `ExpiryCache` class, allowing the cache to rely on `GetUtcNow()` to determine whether cache entries should be evicted based on the current time. This abstraction provides flexibility by enabling different time-related behaviors in test environments. + +By using `FakeTimeProvider` in testing, we can simulate the passage of time with methods like `Advance()` and `SetUtcNow()`. This makes it possible to emulate the system's time in a controlled and predictable way during tests, ensuring that cache eviction works as expected. ```csharp -var timeProvider = new FakeTimeProvider(); -var myComponent = new MyComponent(timeProvider); -timeProvider.Advance(TimeSpan.FromSeconds(5)); -myComponent.CheckState(); +public class ExpiryCache +{ + private readonly TimeProvider _timeProvider; + private readonly ConcurrentDictionary _cache = new(); + private readonly TimeSpan _expirationDuration; + + public ExpiryCache(TimeProvider timeProvider, TimeSpan expirationDuration) + { + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _expirationDuration = expirationDuration; + } + + public void Add(TKey key, TValue value) + { + var expirationTime = _timeProvider.GetUtcNow() + _expirationDuration; + var cacheItem = new CacheItem(value, expirationTime); + + _cache[key] = cacheItem; + } + + public bool TryGetValue(TKey key, out TValue value) + { + value = default; + if (_cache.TryGetValue(key, out TValue cacheItem)) + { + if (cacheItem.ExpirationTime > _timeProvider.GetUtcNow()) + { + value = cacheItem.Value; + return true; + } + + // Remove expired item + _cache.TryRemove(key, out _); + } + return false; + } + + private class CacheItem + { + public TValue Value { get; } + public DateTimeOffset ExpirationTime { get; } + + public CacheItem(TValue value, DateTimeOffset expirationTime) + { + Value = value; + ExpirationTime = expirationTime; + } + } +} + +using Microsoft.Extensions.Time.Testing; + +public class ExpiryCacheTests +{ + [Fact] + public void ExpiryCache_ShouldRemoveExpiredItems() + { + var timeProvider = new FakeTimeProvider(); + var cache = new ExpiryCache(timeProvider, TimeSpan.FromSeconds(3)); + + cache.Add("key1", "value1"); + + // Simulate time passing + timeProvider.SetUtcNow(timeProvider.GetUtcNow() + TimeSpan.FromSeconds(2)); + + // The item should still be in the cache + bool found = cache.TryGetValue("key1", out string value); + Assert.True(found); + Assert.Equal("value1", value); + + // Simulate further time passing to be after expiration time + timeProvider.SetUtcNow(timeProvider.GetUtcNow() + TimeSpan.FromSeconds(2)); + + // The item should now be expired + found = cache.TryGetValue("key1", out value); + Assert.False(found); + } +} ``` -## SynchronizationContext in xUnit Tests +## `SynchronizationContext` in xUnit Tests ### xUnit v2 -Some testing libraries such as xUnit v2 provide custom `SynchronizationContext` for running tests. xUnit v2, for instance, provides `AsyncTestSyncContext` that allows to properly manage asynchronous operations withing the test execution. However, it brings an issue when we test asynchronous code that uses `ConfigureAwait(false)` in combination with class like `FakeTimeProvider`. In such cases, the xUnit context may lose track of the continuation, causing the test to become unresponsive, whether the test itself is asynchronous or not. +Some testing libraries such as xUnit v2 provide custom `SynchronizationContext` for running tests. xUnit v2, for instance, provides `AsyncTestSyncContext` that allows to properly manage asynchronous operations within the test execution. However, it brings an issue when we test asynchronous code that uses `ConfigureAwait(false)` in combination with class like `FakeTimeProvider`. In such cases, the xUnit context may lose track of the continuation, causing the test to become unresponsive, whether the test itself is asynchronous or not. To prevent this issue, remove the xUnit context for tests dependent on `FakeTimeProvider` by setting the synchronization context to `null`: -``` +```csharp SynchronizationContext.SetSynchronizationContext(null) ``` -The `Advance` method is used to simulate the passage of time. Below is an example how to create a test for a code that uses `ConfigureAwait(false)` that ensures that the continuation of the awaited task (i.e., the code that comes after the await statement) works correctly. +The `Advance` method is used to simulate the passage of time. Below is an example how to create a test for a code that uses `ConfigureAwait(false)` that ensures that the continuation of the awaited task (i.e., the code that comes after the await statement) works correctly. For a more realistic example, consider the following test using Polly: -For a more realistic example, consider the following test using Polly: - -```cs +```csharp using Polly; using Polly.Retry; @@ -129,7 +207,7 @@ public class SomeServiceTests ### xUnit v3 -`AsyncTestSyncContext` has been removed more [here](https://xunit.net/docs/getting-started/v3/migration) so described issue is no longer a problem. +`AsyncTestSyncContext` has been removed, more info [here](https://xunit.net/docs/getting-started/v3/migration), so above issue is no longer a problem. ## Feedback & Contributing diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs index 58e218647b4..5c89abae5b8 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs @@ -12,7 +12,7 @@ namespace Microsoft.Extensions.Time.Testing.Test; public class FakeTimeProviderTests { [Fact] - public void DefaultCtor() + public void Constructor_DefaultInitialization_SetsExpectedValues() { var timeProvider = new FakeTimeProvider(); @@ -41,7 +41,7 @@ public void DefaultCtor() } [Fact] - public void RichCtor() + public void Constructor_InitializesWithCustomDateTimeOffset_AdvancesCorrectly() { var timeProvider = new FakeTimeProvider(new DateTimeOffset(2001, 2, 3, 4, 5, 6, TimeSpan.Zero)); @@ -78,7 +78,7 @@ public void RichCtor() } [Fact] - public void LocalTimeZoneIsUtc() + public void LocalTimeZone_Default_IsUtc() { var timeProvider = new FakeTimeProvider(); var localTimeZone = timeProvider.LocalTimeZone; @@ -87,7 +87,7 @@ public void LocalTimeZoneIsUtc() } [Fact] - public void SetLocalTimeZoneWorks() + public void SetLocalTimeZone_CustomTimeZone_SetsNewTimeZone() { var timeProvider = new FakeTimeProvider(); @@ -100,7 +100,7 @@ public void SetLocalTimeZoneWorks() } [Fact] - public void GetTimestampSyncWithUtcNow() + public void SetUtcNow_Forward_AdvancesByProperAmount() { var timeProvider = new FakeTimeProvider(new DateTimeOffset(2001, 2, 3, 4, 5, 6, TimeSpan.Zero)); @@ -124,7 +124,7 @@ public void GetTimestampSyncWithUtcNow() } [Fact] - public void AdvanceGoesForward() + public void Advance_Forward_AdvancesByProperAmount() { var timeProvider = new FakeTimeProvider(new DateTimeOffset(2001, 2, 3, 4, 5, 6, TimeSpan.Zero)); @@ -148,16 +148,23 @@ public void AdvanceGoesForward() } [Fact] - public void TimeCannotGoBackwards() + public void Advance_Backwards_ThrowsArgumentOutOfRangeException() { var timeProvider = new FakeTimeProvider(); Assert.Throws(() => timeProvider.Advance(TimeSpan.FromTicks(-1))); + } + + [Fact] + public void SetUtcNow_Backwards_ThrowsArgumentOutOfRangeException() + { + var timeProvider = new FakeTimeProvider(); + Assert.Throws(() => timeProvider.SetUtcNow(timeProvider.GetUtcNow() - TimeSpan.FromTicks(1))); } [Fact] - public void AdjustTimeForwardWorks() + public void TimerCallback_AdjustTimeForward_Works() { var tp = new FakeTimeProvider(); @@ -184,7 +191,7 @@ public void AdjustTimeForwardWorks() } [Fact] - public void AdjustTimeBackwardWorks() + public void TimerCallback_AdjustTimeBackwards_Works() { var tp = new FakeTimeProvider(); @@ -211,7 +218,7 @@ public void AdjustTimeBackwardWorks() } [Fact] - public void ToStr() + public void ToString_SetDateTimeOffset_ReturnsProperFormat() { var dto = new DateTimeOffset(new DateTime(2022, 1, 2, 3, 4, 5, 6), TimeSpan.Zero); @@ -222,7 +229,7 @@ public void ToStr() private readonly TimeSpan _infiniteTimeout = TimeSpan.FromMilliseconds(-1); [Fact] - public async Task Delay_Zero() + public async Task Delay_ZeroDelay_CompletesSuccessfully() { var timeProvider = new FakeTimeProvider(); var t = timeProvider.Delay(TimeSpan.Zero, CancellationToken.None); @@ -232,7 +239,7 @@ public async Task Delay_Zero() } [Fact] - public async Task Delay_Timeout() + public async Task Delay_Awaited_CompletesSuccessfully() { var timeProvider = new FakeTimeProvider(); @@ -246,7 +253,7 @@ public async Task Delay_Timeout() } [Fact] - public async Task Delay_Cancelled() + public async Task Delay_TokenCancelled_ThrowsTaskCanceledException() { var timeProvider = new FakeTimeProvider(); @@ -262,7 +269,22 @@ public async Task Delay_Cancelled() } [Fact] - public async Task CreateSource() + public async Task Delay_WhenTimeAdvanced_CompletesWithoutCancellation() + { + var fakeTimeProvider = new FakeTimeProvider(); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(1000)); + + var task = fakeTimeProvider.Delay(TimeSpan.FromMilliseconds(10000), cancellationTokenSource.Token); + + fakeTimeProvider.Advance(TimeSpan.FromMilliseconds(10000)); + + await task; + + Assert.False(cancellationTokenSource.Token.IsCancellationRequested); + } + + [Fact] + public async Task Advance_CancelledToken_ThrowsTaskCanceledException() { var timeProvider = new FakeTimeProvider(); @@ -272,9 +294,8 @@ public async Task CreateSource() await Assert.ThrowsAsync(() => timeProvider.Delay(TimeSpan.FromTicks(1), cts.Token)); } -#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks [Fact] - public async Task WaitAsync() + public async Task WaitAsync_NegativeTimeout_Throws() { var timeProvider = new FakeTimeProvider(); var source = new TaskCompletionSource(); @@ -285,6 +306,14 @@ public async Task WaitAsync() await Assert.ThrowsAsync(() => source.Task.WaitAsync(TimeSpan.FromTicks(-1), timeProvider, CancellationToken.None)); #endif await Assert.ThrowsAsync(() => source.Task.WaitAsync(TimeSpan.FromMilliseconds(-2), timeProvider, CancellationToken.None)); + } + +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks + [Fact] + public async Task WaitAsync_ValidTimeout_CompletesSuccessfully() + { + var timeProvider = new FakeTimeProvider(); + var source = new TaskCompletionSource(); var t = source.Task.WaitAsync(TimeSpan.FromSeconds(100000), timeProvider, CancellationToken.None); while (!t.IsCompleted) @@ -301,12 +330,12 @@ public async Task WaitAsync() #pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks [Fact] - public async Task WaitAsync_InfiniteTimeout() + public async Task WaitAsync_InfiniteTimeout_CompletesSuccessfully() { var timeProvider = new FakeTimeProvider(); var source = new TaskCompletionSource(); - var t = source.Task.WaitAsync(_infiniteTimeout, timeProvider, CancellationToken.None); + var t = source.Task.WaitAsync(TimeSpan.FromMilliseconds(-1), timeProvider, CancellationToken.None); while (!t.IsCompleted) { timeProvider.Advance(TimeSpan.FromMilliseconds(1)); @@ -320,7 +349,7 @@ public async Task WaitAsync_InfiniteTimeout() } [Fact] - public async Task WaitAsync_Timeout() + public async Task WaitAsync_Timeout_ResultsInFaultedTask() { var timeProvider = new FakeTimeProvider(); var source = new TaskCompletionSource(); @@ -338,7 +367,7 @@ public async Task WaitAsync_Timeout() } [Fact] - public async Task WaitAsync_Cancel() + public async Task WaitAsync_CancelledToken_ThrowsTaskCanceledException() { var timeProvider = new FakeTimeProvider(); var source = new TaskCompletionSource(); @@ -353,7 +382,7 @@ public async Task WaitAsync_Cancel() } [Fact] - public void AutoAdvance() + public void GetUtcNow_AutoAdvanceSpecified_AutoAdvancesBySpecifiedAmount() { var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow) { @@ -370,7 +399,7 @@ public void AutoAdvance() } [Fact] - public void ToString_AutoAdvance_off() + public void ToString_NoAutoAdvanceSpecified_DoesNotAutoAdvance() { var timeProvider = new FakeTimeProvider(); @@ -380,7 +409,7 @@ public void ToString_AutoAdvance_off() } [Fact] - public void ToString_AutoAdvance_on() + public void ToString_AutoAdvanceSpecified_AutoAdvancesBySpecifiedAmount() { var timeProvider = new FakeTimeProvider { @@ -394,7 +423,7 @@ public void ToString_AutoAdvance_on() } [Fact] - public void AdvanceTimeInCallback() + public void Advance_TimeInCallback_PreventsInfiniteLoop() { var oneSecond = TimeSpan.FromSeconds(1); var timeProvider = new FakeTimeProvider(); @@ -411,7 +440,7 @@ public void AdvanceTimeInCallback() } [Fact] - public void ShouldResetGateUnderLock_PreventingContextSwitching_AffectionOnTimerCallback() + public void GetUtcNow_ResetGateUnderLock_PreventsContextSwitchingIssuesWithTimerCallback() { // Arrange var provider = new FakeTimeProvider { AutoAdvanceAmount = TimeSpan.FromSeconds(2) }; @@ -438,7 +467,7 @@ public void ShouldResetGateUnderLock_PreventingContextSwitching_AffectionOnTimer } [Fact] - public void SimulateRetryPolicy() + public void Advance_PollyRetryWithConfigureAwaitFalse_ProcessesCorrectly() { // Arrange SynchronizationContext.SetSynchronizationContext(null); @@ -468,7 +497,7 @@ async Task simulatedPollyRetry() } catch (InvalidOperationException) { - // ConfigureAwait(true) is required to ensure that tasks continue on the captured context + // ConfigureAwait(false) is required to validate test properly await provider.Delay(TimeSpan.FromSeconds(delay)).ConfigureAwait(false); } } diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs index c0046cc0429..d0db83d943a 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs @@ -18,7 +18,7 @@ private void EmptyTimerTarget(object? o) } [Fact] - public void TimerNonPeriodicPeriodZero() + public void CreateTimer_PeriodZero_FiresOnceAndDoesNotRepeat() { var counter = 0; var timeProvider = new FakeTimeProvider(); @@ -40,7 +40,7 @@ public void TimerNonPeriodicPeriodZero() } [Fact] - public void TimerNonPeriodicPeriodInfinite() + public void CreateTimer_PeriodInfinite_FiresOnceAndDoesNotRepeat() { var counter = 0; var timeProvider = new FakeTimeProvider(); @@ -62,7 +62,7 @@ public void TimerNonPeriodicPeriodInfinite() } [Fact] - public void TimerStartsImmediately() + public void CreateTimer_ImmediateStart_FiresOnceAndDoesNotRepeat() { var counter = 0; var timeProvider = new FakeTimeProvider(); @@ -84,7 +84,7 @@ public void TimerStartsImmediately() } [Fact] - public void NoDueTime_TimerDoesntStart() + public void CreateTimer_InfiniteTimeSpan_DoesNotFire() { var counter = 0; var timeProvider = new FakeTimeProvider(); @@ -106,7 +106,7 @@ public void NoDueTime_TimerDoesntStart() } [Fact] - public void TimerTriggersPeriodically() + public void CreateTimer_PeriodicTrigger_FiresAtSpecifiedIntervals() { var counter = 0; var timeProvider = new FakeTimeProvider(); @@ -133,37 +133,7 @@ public void TimerTriggersPeriodically() } [Fact] - public async Task TaskDelayWithFakeTimeProviderAdvanced() - { - var fakeTimeProvider = new FakeTimeProvider(); - using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(1000)); - - var task = fakeTimeProvider.Delay(TimeSpan.FromMilliseconds(10000), cancellationTokenSource.Token); - - fakeTimeProvider.Advance(TimeSpan.FromMilliseconds(10000)); - - await task; - - Assert.False(cancellationTokenSource.Token.IsCancellationRequested); - } - - [Fact] - public async Task TaskDelayWithFakeTimeProviderStopped() - { - var fakeTimeProvider = new FakeTimeProvider(); - using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); - - await Assert.ThrowsAsync(async () => - { - await fakeTimeProvider.Delay( - TimeSpan.FromMilliseconds(10000), - cancellationTokenSource.Token) - .ConfigureAwait(false); - }); - } - - [Fact] - public void TimerChangeDueTimeOutOfRangeThrows() + public void Change_WhenDueTimeIsOutOfRange_ThrowsArgumentOutOfRangeException() { using var t = new Timer(new FakeTimeProvider(), new TimerCallback(EmptyTimerTarget), null); _ = t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1)); @@ -175,7 +145,7 @@ public void TimerChangeDueTimeOutOfRangeThrows() } [Fact] - public void TimerChangePeriodOutOfRangeThrows() + public void Change_WhenPeriodIsOutOfRange_ThrowsArgumentOutOfRangeException() { using var t = new Timer(new FakeTimeProvider(), new TimerCallback(EmptyTimerTarget), null); _ = t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1)); @@ -187,7 +157,7 @@ public void TimerChangePeriodOutOfRangeThrows() } [Fact] - public void Timer_Change_AfterDispose_Test() + public void Change_WhenCalledAfterDispose_ReturnsFalse() { var t = new Timer(new FakeTimeProvider(), new TimerCallback(EmptyTimerTarget), null); _ = t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1)); @@ -198,7 +168,7 @@ public void Timer_Change_AfterDispose_Test() } [Fact] - public async Task Timer_Change_AfterDisposeAsync_Test() + public async Task Change_WhenCalledAfterDisposeAsync_ReturnsFalse() { var t = new Timer(new FakeTimeProvider(), new TimerCallback(EmptyTimerTarget), null); _ = t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1)); @@ -209,7 +179,7 @@ public async Task Timer_Change_AfterDisposeAsync_Test() } [Fact] - public void WaiterRemovedAfterDispose() + public void CreateTimer_WhenDisposed_RemovesWaiterFromQueue() { var timer1Counter = 0; var timer2Counter = 0; @@ -242,7 +212,7 @@ public void WaiterRemovedAfterDispose() #if RELEASE // In Release only since this might not work if the timer reference being tracked by the debugger [Fact(Skip = "Flaky on .NET Framework")] - public void WaiterRemovedWhenCollectedWithoutDispose() + public void CreateTimer_WhenCollectedWithoutDispose_RemovesWaiterFromQueue() { var timer1Counter = 0; var timer2Counter = 0; @@ -276,7 +246,7 @@ public void WaiterRemovedWhenCollectedWithoutDispose() #endif [Fact] - public void UtcNowUpdatedBeforeTimerCallback() + public void CreateTimer_WhenUtcNowUpdatedBeforeCallback_UpdatesCallbackTime() { var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); var callbackTime = DateTimeOffset.MinValue; @@ -298,7 +268,7 @@ public void UtcNowUpdatedBeforeTimerCallback() } [Fact] - public void LongPausesTriggerMultipleCallbacks() + public void CreateTimer_WhenLongPauses_TriggersMultipleCallbacks() { var callbackTimes = new List(); var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); @@ -323,7 +293,7 @@ public void LongPausesTriggerMultipleCallbacks() } [Fact] - public void MultipleTimersCallbackInvokedInScheduledOrder() + public void CreateMultipleTimers_WhenAdvanced_InvokesCallbacksInScheduledOrder() { var callbacks = new List<(int timerId, TimeSpan callbackTime)>(); var timeProvider = new FakeTimeProvider(); @@ -352,7 +322,7 @@ public void MultipleTimersCallbackInvokedInScheduledOrder() } [Fact] - public void OutOfOrderWakeTimes() + public void CreateMultipleTimers_WhenAdvanced_TriggersCallbacksInOrder() { const int MaxDueTime = 10; const int TotalTimers = 128; From d8e2cd29165360b2eeb30f6305ccdf09d3509511 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 14:09:07 +0000 Subject: [PATCH 165/190] Update dependencies from https://github.com/dotnet/aspnetcore build 20241127.14 (#5703) [main] Update dependencies from dotnet/aspnetcore --- NuGet.config | 2 +- eng/Version.Details.xml | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/NuGet.config b/NuGet.config index a724678cd40..b10b99e2a98 100644 --- a/NuGet.config +++ b/NuGet.config @@ -4,7 +4,7 @@ - + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 2582655ecdf..024e2e3d7f3 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -150,39 +150,39 @@ https://github.com/dotnet/aspnetcore - 97de658c5eb540a63d85941a7678fd4bc9db5d37 + 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef https://github.com/dotnet/aspnetcore - 97de658c5eb540a63d85941a7678fd4bc9db5d37 + 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef https://github.com/dotnet/aspnetcore - 97de658c5eb540a63d85941a7678fd4bc9db5d37 + 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef https://github.com/dotnet/aspnetcore - 97de658c5eb540a63d85941a7678fd4bc9db5d37 + 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef https://github.com/dotnet/aspnetcore - 97de658c5eb540a63d85941a7678fd4bc9db5d37 + 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef https://github.com/dotnet/aspnetcore - 97de658c5eb540a63d85941a7678fd4bc9db5d37 + 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef https://github.com/dotnet/aspnetcore - 97de658c5eb540a63d85941a7678fd4bc9db5d37 + 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef https://github.com/dotnet/aspnetcore - 97de658c5eb540a63d85941a7678fd4bc9db5d37 + 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef https://github.com/dotnet/aspnetcore - 97de658c5eb540a63d85941a7678fd4bc9db5d37 + 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef From 5161cb90e1db3c3b6192ce40a3406dabc53db35a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 29 Nov 2024 16:07:11 +0000 Subject: [PATCH 166/190] For AI integration tests, use config including user secrets (#5706) --- eng/packages/TestOnly.props | 2 + .../Microsoft.Extensions.AI/README.md | 58 +++++++++++++++++++ ...reAIInferenceChatClientIntegrationTests.cs | 4 +- ...renceEmbeddingGeneratorIntegrationTests.cs | 4 +- .../IntegrationTestHelpers.cs | 6 +- ...oft.Extensions.AI.Integration.Tests.csproj | 3 + .../TestRunnerConfiguration.cs | 16 +++++ .../IntegrationTestHelpers.cs | 10 ++-- .../OpenAIChatClientIntegrationTests.cs | 4 +- ...penAIEmbeddingGeneratorIntegrationTests.cs | 4 +- .../OpenAIRealtimeIntegrationTests.cs | 2 +- 11 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.AI.Integration.Tests/TestRunnerConfiguration.cs diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 6443d61c224..dce0b4a0ba1 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -10,6 +10,8 @@ + + diff --git a/src/Libraries/Microsoft.Extensions.AI/README.md b/src/Libraries/Microsoft.Extensions.AI/README.md index ef092749200..eb091eb0435 100644 --- a/src/Libraries/Microsoft.Extensions.AI/README.md +++ b/src/Libraries/Microsoft.Extensions.AI/README.md @@ -25,3 +25,61 @@ Please refer to the [README](https://www.nuget.org/packages/Microsoft.Extensions ## Feedback & Contributing We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). + +## Running the integration tests + +If you're working on this repo and want to run the integration tests, e.g., those in `Microsoft.Extensions.AI.OpenAI.Tests`, you must first set endpoints and keys. You can either set these as environment variables or - better - using .NET's user secrets feature as shown below. + +### Configuring OpenAI tests (OpenAI) + +Run commands like the following. The settings will be saved in your user profile. + +``` +cd test/Libraries/Microsoft.Extensions.AI.Integration.Tests +dotnet user-secrets set OpenAI:Mode OpenAI +dotnet user-secrets set OpenAI:Key abcdefghijkl +``` + +Optionally also run the following. The values shown here are the defaults if you don't specify otherwise: + +``` +dotnet user-secrets set OpenAI:ChatModel gpt-4o-mini +dotnet user-secrets set OpenAI:EmbeddingModel text-embedding-3-small +``` + +### Configuring OpenAI tests (Azure OpenAI) + +Run commands like the following. The settings will be saved in your user profile. + +``` +cd test/Libraries/Microsoft.Extensions.AI.Integration.Tests +dotnet user-secrets set OpenAI:Mode AzureOpenAI +dotnet user-secrets set OpenAI:Endpoint https://YOUR_DEPLOYMENT.openai.azure.com/ +dotnet user-secrets set OpenAI:Key abcdefghijkl +``` + +Optionally also run the following. The values shown here are the defaults if you don't specify otherwise: + +``` +dotnet user-secrets set OpenAI:ChatModel gpt-4o-mini +dotnet user-secrets set OpenAI:EmbeddingModel text-embedding-3-small +``` + +Your account must have models matching these names. + +### Configuring Azure AI Inference tests + +Run commands like the following. The settings will be saved in your user profile. + +``` +cd test/Libraries/Microsoft.Extensions.AI.Integration.Tests +dotnet user-secrets set AzureAIInference:Endpoint https://YOUR_DEPLOYMENT.azure.com/ +dotnet user-secrets set AzureAIInference:Key abcdefghijkl +``` + +Optionally also run the following. The values shown here are the defaults if you don't specify otherwise: + +``` +dotnet user-secrets set AzureAIInference:ChatModel gpt-4o-mini +dotnet user-secrets set AzureAIInference:EmbeddingModel text-embedding-3-small +``` diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs index a42f1bd4ddf..1a4c0921838 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientIntegrationTests.cs @@ -1,13 +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; - namespace Microsoft.Extensions.AI; public class AzureAIInferenceChatClientIntegrationTests : ChatClientIntegrationTests { protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetChatCompletionsClient() - ?.AsChatClient(Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_CHAT_MODEL") ?? "gpt-4o-mini"); + ?.AsChatClient(TestRunnerConfiguration.Instance["AzureAIInference:ChatModel"] ?? "gpt-4o-mini"); } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs index 637c1475747..a5afde64578 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorIntegrationTests.cs @@ -1,13 +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; - namespace Microsoft.Extensions.AI; public class AzureAIInferenceEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests { protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => IntegrationTestHelpers.GetEmbeddingsClient() - ?.AsEmbeddingGenerator(Environment.GetEnvironmentVariable("OPENAI_EMBEDDING_MODEL") ?? "text-embedding-3-small"); + ?.AsEmbeddingGenerator(TestRunnerConfiguration.Instance["AzureAIInference:EmbeddingModel"] ?? "text-embedding-3-small"); } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs index e1a2076a6c7..7518d987cc4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/IntegrationTestHelpers.cs @@ -11,11 +11,11 @@ namespace Microsoft.Extensions.AI; internal static class IntegrationTestHelpers { private static readonly string? _apiKey = - Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_APIKEY") ?? - Environment.GetEnvironmentVariable("OPENAI_API_KEY"); + TestRunnerConfiguration.Instance["AzureAIInference:Key"] ?? + TestRunnerConfiguration.Instance["OpenAI:Key"]; private static readonly string _endpoint = - Environment.GetEnvironmentVariable("AZURE_AI_INFERENCE_ENDPOINT") ?? + TestRunnerConfiguration.Instance["AzureAIInference:Endpoint"] ?? "https://api.openai.com/v1"; /// Gets an to use for testing, or null if the associated tests should be disabled. 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 250c76e9d69..dc7703a8eb0 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 @@ -2,6 +2,7 @@ Microsoft.Extensions.AI Opt-in integration tests for Microsoft.Extensions.AI. + 2ddf3914-75d2-4677-96e8-2e583ca87838 @@ -25,6 +26,8 @@ + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/TestRunnerConfiguration.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/TestRunnerConfiguration.cs new file mode 100644 index 00000000000..1f521fa0005 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/TestRunnerConfiguration.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.AI; + +public static class TestRunnerConfiguration +{ + public static IConfiguration Instance { get; } = new ConfigurationBuilder() + .AddUserSecrets() + .AddEnvironmentVariables() + .Build(); + + private class TypeInThisAssembly; +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs index da60e62061f..be0cb85daf6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs @@ -4,6 +4,7 @@ using System; using System.ClientModel; using Azure.AI.OpenAI; +using Microsoft.Extensions.Configuration; using OpenAI; namespace Microsoft.Extensions.AI; @@ -14,14 +15,15 @@ internal static class IntegrationTestHelpers /// Gets an to use for testing, or null if the associated tests should be disabled. public static OpenAIClient? GetOpenAIClient() { - string? apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"); + var configuration = TestRunnerConfiguration.Instance; + string? apiKey = configuration["OpenAI:Key"]; if (apiKey is not null) { - if (string.Equals(Environment.GetEnvironmentVariable("OPENAI_MODE"), "AzureOpenAI", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(configuration["OpenAI:Mode"], "AzureOpenAI", StringComparison.OrdinalIgnoreCase)) { - var endpoint = Environment.GetEnvironmentVariable("OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("To use AzureOpenAI, set a value for OPENAI_ENDPOINT"); + var endpoint = configuration["OpenAI:Endpoint"] + ?? throw new InvalidOperationException("To use AzureOpenAI, set a value for OpenAI:Endpoint"); return new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey)); } else diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs index c82e1abc860..04ea982d854 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs @@ -1,13 +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; - namespace Microsoft.Extensions.AI; public class OpenAIChatClientIntegrationTests : ChatClientIntegrationTests { protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetOpenAIClient() - ?.AsChatClient(Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL") ?? "gpt-4o-mini"); + ?.AsChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini"); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs index 38283e2687b..2c48e3287df 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorIntegrationTests.cs @@ -1,13 +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; - namespace Microsoft.Extensions.AI; public class OpenAIEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests { protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => IntegrationTestHelpers.GetOpenAIClient() - ?.AsEmbeddingGenerator(Environment.GetEnvironmentVariable("OPENAI_EMBEDDING_MODEL") ?? "text-embedding-3-small"); + ?.AsEmbeddingGenerator(TestRunnerConfiguration.Instance["OpenAI:EmbeddingModel"] ?? "text-embedding-3-small"); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs index 46b9fac7cab..5a5e7040159 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRealtimeIntegrationTests.cs @@ -102,7 +102,7 @@ protected void SkipIfNotEnabled() private static RealtimeConversationClient? CreateConversationClient() { - var realtimeModel = Environment.GetEnvironmentVariable("OPENAI_REALTIME_MODEL"); + var realtimeModel = TestRunnerConfiguration.Instance["OpenAI:RealtimeModel"]; if (string.IsNullOrEmpty(realtimeModel)) { return null; From 1120b299e3923daeaf9e6153d0e05b7134a811bd Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 2 Dec 2024 22:13:57 -0500 Subject: [PATCH 167/190] Fix handling of text-only user messages in AzureAIInferenceChatClient (#5714) * Fix handling of text-only user messages in AzureAIInferenceChatClient `ChatRequestUserMessage` has three constructors: ```csharp public ChatRequestUserMessage(string content) public ChatRequestUserMessage(IEnumerable content) public ChatRequestUserMessage(params ChatMessageContentItem[] content) ``` but even though all of the parameters are named `content` and represent the message's content, they behave differently. The first assigns the string content to the instance's `Content` property and leaves its `MultimodalContentItems` property null, and the others leave `Content` null and set `MultimodalContentItems` to the property. For models that don't support multi-modal, using the latter two constructors breaks, even when the content is a single text item. I think this should be improved in Azure.AI.Inference, but regardless, this fixes the ToAzureAIInferenceChatMessages helper to special-case text-only inputs and use the first `string`-based constructor rather than always using the `IEnumerable`-based one. * Include all assistant text content, too * Add some tests to fix code coverage --- .../AzureAIInferenceChatClient.cs | 17 +- ...soft.Extensions.AI.AzureAIInference.csproj | 2 +- .../AzureAIInferenceChatClientTests.cs | 338 +++++++++++++++--- 3 files changed, 305 insertions(+), 52 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 7a4d24abd5e..1ec1225a9dc 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -405,7 +407,7 @@ private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunc } /// Converts an Extensions chat message enumerable to an AzureAI chat message enumerable. - private IEnumerable ToAzureAIInferenceChatMessages(IEnumerable inputs) + private IEnumerable ToAzureAIInferenceChatMessages(IList inputs) { // Maps all of the M.E.AI types to the corresponding AzureAI types. // Unrecognized or non-processable content is ignored. @@ -441,13 +443,15 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab } else if (input.Role == ChatRole.User) { - yield return new ChatRequestUserMessage(GetContentParts(input.Contents)); + yield return input.Contents.All(c => c is TextContent) ? + new ChatRequestUserMessage(string.Concat(input.Contents)) : + new ChatRequestUserMessage(GetContentParts(input.Contents)); } else if (input.Role == ChatRole.Assistant) { // TODO: ChatRequestAssistantMessage only enables text content currently. // Update it with other content types when it supports that. - ChatRequestAssistantMessage message = new(input.Text ?? string.Empty); + ChatRequestAssistantMessage message = new(string.Concat(input.Contents.Where(c => c is TextContent))); foreach (var content in input.Contents) { @@ -469,6 +473,8 @@ private IEnumerable ToAzureAIInferenceChatMessages(IEnumerab /// Converts a list of to a list of . private static List GetContentParts(IList contents) { + Debug.Assert(contents is { Count: > 0 }, "Expected non-empty contents"); + List parts = []; foreach (var content in contents) { @@ -488,11 +494,6 @@ private static List GetContentParts(IList con } } - if (parts.Count == 0) - { - parts.Add(new ChatMessageTextContentItem(string.Empty)); - } - return parts; } 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 0c1f162542b..6896a186d0a 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 @@ -9,7 +9,7 @@ preview true - 77 + 83 0 diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index da2b1923749..3cd2fd16e33 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -16,6 +16,8 @@ using Xunit; #pragma warning disable S103 // Lines should not be too long +#pragma warning disable S3358 // Ternary operators should not be nested +#pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; @@ -88,16 +90,23 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() 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()); + + Assert.Null(pipeline.GetService("key")); + Assert.Null(pipeline.GetService("key")); + Assert.Null(pipeline.GetService("key")); } - [Fact] - public async Task BasicRequestResponse_NonStreaming() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BasicRequestResponse_NonStreaming(bool multiContent) { const string Input = """ - {"messages":[{"content":[{"text":"hello","type":"text"}],"role":"user"}],"max_tokens":10,"temperature":0.5,"model":"gpt-4o-mini"} + {"messages":[{"content":"hello","role":"user"}],"max_tokens":10,"temperature":0.5,"model":"gpt-4o-mini"} """; const string Output = """ @@ -137,7 +146,11 @@ public async Task BasicRequestResponse_NonStreaming() using HttpClient httpClient = new(handler); using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); - var response = await client.CompleteAsync("hello", new() + List chatMessages = multiContent ? + [new ChatMessage(ChatRole.User, "hello".Select(c => (AIContent)new TextContent(c.ToString())).ToList())] : + [new ChatMessage(ChatRole.User, "hello")]; + + var response = await client.CompleteAsync(chatMessages, new() { MaxOutputTokens = 10, Temperature = 0.5f, @@ -158,11 +171,13 @@ public async Task BasicRequestResponse_NonStreaming() Assert.Equal(17, response.Usage.TotalTokenCount); } - [Fact] - public async Task BasicRequestResponse_Streaming() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BasicRequestResponse_Streaming(bool multiContent) { const string Input = """ - {"messages":[{"content":[{"text":"hello","type":"text"}],"role":"user"}],"max_tokens":20,"temperature":0.5,"stream":true,"model":"gpt-4o-mini"} + {"messages":[{"content":"hello","role":"user"}],"max_tokens":20,"temperature":0.5,"stream":true,"model":"gpt-4o-mini"} """; const string Output = """ @@ -198,8 +213,12 @@ public async Task BasicRequestResponse_Streaming() using HttpClient httpClient = new(handler); using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + List chatMessages = multiContent ? + [new ChatMessage(ChatRole.User, "hello".Select(c => (AIContent)new TextContent(c.ToString())).ToList())] : + [new ChatMessage(ChatRole.User, "hello")]; + List updates = []; - await foreach (var update in client.CompleteStreamingAsync("hello", new() + await foreach (var update in client.CompleteStreamingAsync(chatMessages, new() { MaxOutputTokens = 20, Temperature = 0.5f, @@ -223,6 +242,184 @@ public async Task BasicRequestResponse_Streaming() } } + [Fact] + public async Task AdditionalOptions_NonStreaming() + { + const string Input = """ + { + "messages":[{"content":"hello","role":"user"}], + "max_tokens":10, + "temperature":0.5, + "top_p":0.5, + "stop":["yes","no"], + "presence_penalty":0.5, + "frequency_penalty":0.75, + "seed":42, + "model":"gpt-4o-mini", + "top_k":40, + "something_else":"value1", + "and_something_further":123 + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.NotNull(await client.CompleteAsync("hello", new() + { + MaxOutputTokens = 10, + Temperature = 0.5f, + TopP = 0.5f, + TopK = 40, + FrequencyPenalty = 0.75f, + PresencePenalty = 0.5f, + Seed = 42, + StopSequences = ["yes", "no"], + AdditionalProperties = new() + { + ["something_else"] = "value1", + ["and_something_further"] = 123, + }, + })); + } + + [Fact] + public async Task ResponseFormat_Text_NonStreaming() + { + const string Input = """ + { + "messages":[{"content":"hello","role":"user"}], + "model":"gpt-4o-mini", + "response_format":{"type":"text"} + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.NotNull(await client.CompleteAsync("hello", new() + { + ResponseFormat = ChatResponseFormat.Text, + })); + } + + [Fact] + public async Task ResponseFormat_Json_NonStreaming() + { + const string Input = """ + { + "messages":[{"content":"hello","role":"user"}], + "model":"gpt-4o-mini", + "response_format":{"type":"json_object"} + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.NotNull(await client.CompleteAsync("hello", new() + { + ResponseFormat = ChatResponseFormat.Json, + })); + } + + [Fact] + public async Task ResponseFormat_JsonSchema_NonStreaming() + { + // NOTE: Azure.AI.Inference doesn't yet expose JSON schema support, so it's currently + // mapped to "json_object" for the time being. + + const string Input = """ + { + "messages":[{"content":"hello","role":"user"}], + "model":"gpt-4o-mini", + "response_format":{"type":"json_object"} + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.NotNull(await client.CompleteAsync("hello", new() + { + ResponseFormat = ChatResponseFormat.ForJsonSchema(""" + { + "type": "object", + "properties": { + "description": { + "type": "string" + } + }, + "required": ["description"] + } + """, "DescribedObject", "An object with a description"), + })); + } + [Fact] public async Task MultipleMessages_NonStreaming() { @@ -234,12 +431,7 @@ public async Task MultipleMessages_NonStreaming() "role": "system" }, { - "content": [ - { - "text": "hello!", - "type": "text" - } - ], + "content": "hello!", "role": "user" }, { @@ -247,13 +439,18 @@ public async Task MultipleMessages_NonStreaming() "role": "assistant" }, { - "content": [ - { - "text": "i\u0027m good. how are you?", - "type": "text" - } - ], + "content": "i\u0027m good. how are you?", "role": "user" + }, + { + "content": "", + "tool_calls": [{"id":"abcd123","type":"function","function":{"name":"GetMood","arguments":"null"}}], + "role": "assistant" + }, + { + "content": "happy", + "tool_call_id": "abcd123", + "role": "tool" } ], "temperature": 0.25, @@ -310,6 +507,8 @@ public async Task MultipleMessages_NonStreaming() new(ChatRole.User, "hello!"), new(ChatRole.Assistant, "hi, how are you?"), new(ChatRole.User, "i'm good. how are you?"), + new(ChatRole.Assistant, [new FunctionCallContent("abcd123", "GetMood")]), + new(ChatRole.Tool, [new FunctionResultContent("abcd123", "GetMood", "happy")]), ]; var response = await client.CompleteAsync(messages, new() @@ -336,6 +535,61 @@ public async Task MultipleMessages_NonStreaming() Assert.Equal(57, response.Usage.TotalTokenCount); } + [Fact] + public async Task MultipleContent_NonStreaming() + { + const string Input = """ + { + "messages": + [ + { + "content": + [ + { + "text": "Describe this picture.", + "type": "text" + }, + { + "image_url": + { + "url": "http://dot.net/someimage.png" + }, + "type": "image_url" + } + ], + "role":"user" + } + ], + "model": "gpt-4o-mini" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADyV17bXeSm5rzUx3n46O7m3M0o3P", + "object": "chat.completion", + "choices": [ + { + "message": { + "role": "assistant", + "content": "A picture of a dog." + } + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.NotNull(await client.CompleteAsync([new(ChatRole.User, + [ + new TextContent("Describe this picture."), + new ImageContent("http://dot.net/someimage.png"), + ])])); + } + [Fact] public async Task NullAssistantText_ContentEmpty_NonStreaming() { @@ -347,12 +601,7 @@ public async Task NullAssistantText_ContentEmpty_NonStreaming() "role": "assistant" }, { - "content": [ - { - "text": "hello!", - "type": "text" - } - ], + "content": "hello!", "role": "user" } ], @@ -420,19 +669,22 @@ public async Task NullAssistantText_ContentEmpty_NonStreaming() Assert.Equal(57, response.Usage.TotalTokenCount); } - [Fact] - public async Task FunctionCallContent_NonStreaming() + public static IEnumerable FunctionCallContent_NonStreaming_MemberData() { - const string Input = """ + yield return [ChatToolMode.Auto]; + yield return [ChatToolMode.RequireAny]; + yield return [ChatToolMode.RequireSpecific("GetPersonAge")]; + } + + [Theory] + [MemberData(nameof(FunctionCallContent_NonStreaming_MemberData))] + public async Task FunctionCallContent_NonStreaming(ChatToolMode mode) + { + string input = $$""" { "messages": [ { - "content": [ - { - "text": "How old is Alice?", - "type": "text" - } - ], + "content": "How old is Alice?", "role": "user" } ], @@ -456,7 +708,11 @@ public async Task FunctionCallContent_NonStreaming() } } ], - "tool_choice": "auto" + "tool_choice": {{( + mode is AutoChatToolMode ? "\"auto\"" : + mode is RequiredChatToolMode { RequiredFunctionName: not null } f ? "{\"type\":\"function\",\"function\":{\"name\":\"GetPersonAge\"}}" : + "\"required\"" + )}} } """; @@ -503,13 +759,14 @@ public async Task FunctionCallContent_NonStreaming() } """; - using VerbatimHttpHandler handler = new(Input, Output); + using VerbatimHttpHandler handler = new(input, Output); using HttpClient httpClient = new(handler); using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); var response = await client.CompleteAsync("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.")], + ToolMode = mode, }); Assert.NotNull(response); @@ -537,12 +794,7 @@ public async Task FunctionCallContent_Streaming() { "messages": [ { - "content": [ - { - "text": "How old is Alice?", - "type": "text" - } - ], + "content": "How old is Alice?", "role": "user" } ], From e8efa1f1c551efa729be9263fcf864addfef74b1 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 3 Dec 2024 12:48:41 -0500 Subject: [PATCH 168/190] Make UseLogging a nop when NullLoggerFactory is used (#5717) --- .../LoggingChatClientBuilderExtensions.cs | 9 +++++++++ ...gingEmbeddingGeneratorBuilderExtensions.cs | 9 +++++++++ .../TestChatClient.cs | 10 +++++++++- .../TestEmbeddingGenerator.cs | 10 +++++++++- .../ChatCompletion/LoggingChatClientTests.cs | 19 +++++++++++++++++++ .../LoggingEmbeddingGeneratorTests.cs | 19 +++++++++++++++++++ 6 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs index 61221af01a4..6ae8d176e5e 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClientBuilderExtensions.cs @@ -4,6 +4,7 @@ using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -29,6 +30,14 @@ public static ChatClientBuilder UseLogging( return builder.Use((innerClient, services) => { loggerFactory ??= services.GetRequiredService(); + + // If the factory we resolve is for the null logger, the LoggingChatClient will end up + // being an expensive nop, so skip adding it and just return the inner client. + if (loggerFactory == NullLoggerFactory.Instance) + { + return innerClient; + } + var chatClient = new LoggingChatClient(innerClient, loggerFactory.CreateLogger(typeof(LoggingChatClient))); configure?.Invoke(chatClient); return chatClient; diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs index 0ea85e7baaa..52fb7dd1ca3 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/LoggingEmbeddingGeneratorBuilderExtensions.cs @@ -4,6 +4,7 @@ using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -32,6 +33,14 @@ public static EmbeddingGeneratorBuilder UseLogging { loggerFactory ??= services.GetRequiredService(); + + // If the factory we resolve is for the null logger, the LoggingEmbeddingGenerator will end up + // being an expensive nop, so skip adding it and just return the inner generator. + if (loggerFactory == NullLoggerFactory.Instance) + { + return innerGenerator; + } + var generator = new LoggingEmbeddingGenerator(innerGenerator, loggerFactory.CreateLogger(typeof(LoggingEmbeddingGenerator))); configure?.Invoke(generator); return generator; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs index 64a632d0846..e0f8c7fe982 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestChatClient.cs @@ -10,6 +10,11 @@ namespace Microsoft.Extensions.AI; public sealed class TestChatClient : IChatClient { + public TestChatClient() + { + GetServiceCallback = DefaultGetServiceCallback; + } + public IServiceProvider? Services { get; set; } public ChatClientMetadata Metadata { get; set; } = new(); @@ -18,7 +23,10 @@ public sealed class TestChatClient : IChatClient public Func, ChatOptions?, CancellationToken, IAsyncEnumerable>? CompleteStreamingAsyncCallback { get; set; } - public Func GetServiceCallback { get; set; } = (_, _) => null; + public Func GetServiceCallback { get; set; } + + private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) => + serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; public Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) => CompleteAsyncCallback!.Invoke(chatMessages, options, cancellationToken); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs index 7438edc752e..fd85eb52391 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs @@ -10,11 +10,19 @@ namespace Microsoft.Extensions.AI; public sealed class TestEmbeddingGenerator : IEmbeddingGenerator> { + public TestEmbeddingGenerator() + { + GetServiceCallback = DefaultGetServiceCallback; + } + public EmbeddingGeneratorMetadata Metadata { get; } = new(); public Func, EmbeddingGenerationOptions?, CancellationToken, Task>>>? GenerateAsyncCallback { get; set; } - public Func GetServiceCallback { get; set; } = (_, _) => null; + public Func GetServiceCallback { get; set; } + + private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) => + serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) => GenerateAsyncCallback!.Invoke(values, options, cancellationToken); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs index 66abd7f6612..5f3ab83439f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/LoggingChatClientTests.cs @@ -21,6 +21,25 @@ public void LoggingChatClient_InvalidArgs_Throws() Assert.Throws("logger", () => new LoggingChatClient(new TestChatClient(), null!)); } + [Fact] + public void UseLogging_AvoidsInjectingNopClient() + { + using var innerClient = new TestChatClient(); + + Assert.Null(innerClient.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(LoggingChatClient))); + Assert.Same(innerClient, innerClient.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(IChatClient))); + + using var factory = LoggerFactory.Create(b => b.AddFakeLogging()); + Assert.NotNull(innerClient.AsBuilder().UseLogging(factory).Build().GetService(typeof(LoggingChatClient))); + + ServiceCollection c = new(); + c.AddFakeLogging(); + var services = c.BuildServiceProvider(); + Assert.NotNull(innerClient.AsBuilder().UseLogging().Build(services).GetService(typeof(LoggingChatClient))); + Assert.NotNull(innerClient.AsBuilder().UseLogging(null).Build(services).GetService(typeof(LoggingChatClient))); + Assert.Null(innerClient.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build(services).GetService(typeof(LoggingChatClient))); + } + [Theory] [InlineData(LogLevel.Trace)] [InlineData(LogLevel.Debug)] diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs index d4ab06a8667..bc4a73fdeb7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/LoggingEmbeddingGeneratorTests.cs @@ -20,6 +20,25 @@ public void LoggingEmbeddingGenerator_InvalidArgs_Throws() Assert.Throws("logger", () => new LoggingEmbeddingGenerator>(new TestEmbeddingGenerator(), null!)); } + [Fact] + public void UseLogging_AvoidsInjectingNopClient() + { + using var innerGenerator = new TestEmbeddingGenerator(); + + Assert.Null(innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(LoggingEmbeddingGenerator>))); + Assert.Same(innerGenerator, innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(IEmbeddingGenerator>))); + + using var factory = LoggerFactory.Create(b => b.AddFakeLogging()); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging(factory).Build().GetService(typeof(LoggingEmbeddingGenerator>))); + + ServiceCollection c = new(); + c.AddFakeLogging(); + var services = c.BuildServiceProvider(); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging().Build(services).GetService(typeof(LoggingEmbeddingGenerator>))); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging(null).Build(services).GetService(typeof(LoggingEmbeddingGenerator>))); + Assert.Null(innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build(services).GetService(typeof(LoggingEmbeddingGenerator>))); + } + [Theory] [InlineData(LogLevel.Trace)] [InlineData(LogLevel.Debug)] From 6734c8f3d6f7f3d25f03ed83267743c5289794da Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 3 Dec 2024 19:59:16 +0000 Subject: [PATCH 169/190] Fix streaming function calling (#5718) * Fix streaming function calling * Rename test --- .../FunctionInvokingChatClient.cs | 2 +- .../FunctionInvokingChatClientTests.cs | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index e1e4542d5d0..20c70eb05d4 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -325,7 +325,7 @@ public override async IAsyncEnumerable CompleteSt // If there were any, remove them from the update. We do this before yielding the update so // that we're not modifying an instance already provided back to the caller. int addedFccs = functionCallContents.Count - preFccCount; - if (addedFccs > preFccCount) + if (addedFccs > 0) { update.Contents = addedFccs == update.Contents.Count ? [] : update.Contents.Where(c => c is not FunctionCallContent).ToList(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 1dc91797037..a274c6225a7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -494,6 +494,58 @@ async Task InvokeAsync(Func work) } } + [Fact] + public async Task SupportsConsecutiveStreamingUpdatesWithFunctionCalls() + { + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create((string text) => $"Result for {text}", "Func1")] + }; + + var messages = new List + { + new(ChatRole.User, "Hello"), + }; + + using var innerClient = new TestChatClient + { + CompleteStreamingAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + // If the conversation is just starting, issue two consecutive updates with function calls + // Otherwise just end the conversation + return chatContents.Last().Text == "Hello" + ? YieldAsync( + new StreamingChatCompletionUpdate { Contents = [new FunctionCallContent("callId1", "Func1", new Dictionary { ["text"] = "Input 1" })] }, + new StreamingChatCompletionUpdate { Contents = [new FunctionCallContent("callId2", "Func1", new Dictionary { ["text"] = "Input 2" })] }) + : YieldAsync( + new StreamingChatCompletionUpdate { Contents = [new TextContent("OK bye")] }); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + var updates = new List(); + await foreach (var update in client.CompleteStreamingAsync(messages, options, CancellationToken.None)) + { + updates.Add(update); + } + + // Message history should now include the FCCs and FRCs + Assert.Collection(messages, + m => Assert.Equal("Hello", Assert.IsType(Assert.Single(m.Contents)).Text), + m => Assert.Collection(m.Contents, + c => Assert.Equal("Input 1", Assert.IsType(c).Arguments!["text"]), + c => Assert.Equal("Input 2", Assert.IsType(c).Arguments!["text"])), + m => Assert.Collection(m.Contents, + c => Assert.Equal("Result for Input 1", Assert.IsType(c).Result?.ToString()), + c => Assert.Equal("Result for Input 2", Assert.IsType(c).Result?.ToString()))); + + // The returned updates should *not* include the FCCs and FRCs + var allUpdateContents = updates.SelectMany(updates => updates.Contents).ToList(); + var singleUpdateContent = Assert.IsType(Assert.Single(allUpdateContents)); + Assert.Equal("OK bye", singleUpdateContent.Text); + } + private static async Task> InvokeAndAssertAsync( ChatOptions options, List plan, From f7ec51721001a0b9a8ddee174c5545448be4e814 Mon Sep 17 00:00:00 2001 From: Iliar Turdushev Date: Wed, 4 Dec 2024 10:24:37 +0100 Subject: [PATCH 170/190] Removes Experimental attribute from ResilienceHandler class (#5670) * Fixes #5669 Removes experimental attribute from ResilienceHandler class * Fixes #5669 Adds new experimental and stable APIs to the API json file * Fixes #5669 Suppress LA0006 warning causing the build pipeline to fail --- scripts/MakeApiBaselines.ps1 | 4 +- ...icrosoft.Extensions.Http.Resilience.csproj | 6 +++ .../Microsoft.Extensions.Http.Resilience.json | 48 ++++++++++++++++++- .../Resilience/ResilienceHandler.cs | 3 -- 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/scripts/MakeApiBaselines.ps1 b/scripts/MakeApiBaselines.ps1 index 09315a102ed..dcebd769a3e 100644 --- a/scripts/MakeApiBaselines.ps1 +++ b/scripts/MakeApiBaselines.ps1 @@ -16,7 +16,7 @@ Write-Output "Installing required toolset" InitializeDotNetCli -install $true | Out-Null $Project = $PSScriptRoot + "/../eng/Tools/ApiChief/ApiChief.csproj" -$Command = $PSScriptRoot + "/../artifacts/bin/ApiChief/Debug/net8.0/ApiChief.dll" +$Command = $PSScriptRoot + "/../artifacts/bin/ApiChief/Debug/net9.0/ApiChief.dll" $LibrariesFolder = $PSScriptRoot + "/../src/Libraries" Write-Output "Building ApiChief tool" @@ -28,7 +28,7 @@ Write-Output "Creating API baseline files in the src/Libraries folder" Get-ChildItem -Path $LibrariesFolder -Depth 1 -Include *.csproj | ForEach-Object ` { $name = Split-Path $_.FullName -LeafBase - $path = "$PSScriptRoot\..\artifacts\bin\$name\Debug\net8.0\$name.dll" + $path = "$PSScriptRoot\..\artifacts\bin\$name\Debug\net9.0\$name.dll" Write-Host " Processing" $name dotnet $Command $path emit baseline -o "$LibrariesFolder/$name/$name.json" } 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 f0499dada26..8d280d747cb 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 @@ -25,6 +25,12 @@ 100 + + + $(NoWarn);LA0006 + + diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.json b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.json index 4b192650a52..15644943c60 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.json +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.Http.Resilience, Version=8.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.Http.Resilience, Version=9.1.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "class Microsoft.Extensions.Http.Resilience.HedgingEndpointOptions", @@ -42,6 +42,10 @@ { "Member": "static bool Microsoft.Extensions.Http.Resilience.HttpClientHedgingResiliencePredicates.IsTransient(Polly.Outcome outcome);", "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.Http.Resilience.HttpClientHedgingResiliencePredicates.IsTransient(Polly.Outcome outcome, System.Threading.CancellationToken cancellationToken);", + "Stage": "Experimental" } ] }, @@ -52,6 +56,10 @@ { "Member": "static bool Microsoft.Extensions.Http.Resilience.HttpClientResiliencePredicates.IsTransient(Polly.Outcome outcome);", "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.Http.Resilience.HttpClientResiliencePredicates.IsTransient(Polly.Outcome outcome, System.Threading.CancellationToken cancellationToken);", + "Stage": "Experimental" } ] }, @@ -75,6 +83,20 @@ } ] }, + { + "Type": "static class Polly.HttpResilienceContextExtensions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static System.Net.Http.HttpRequestMessage? Polly.HttpResilienceContextExtensions.GetRequestMessage(this Polly.ResilienceContext context);", + "Stage": "Experimental" + }, + { + "Member": "static void Polly.HttpResilienceContextExtensions.SetRequestMessage(this Polly.ResilienceContext context, System.Net.Http.HttpRequestMessage? requestMessage);", + "Stage": "Experimental" + } + ] + }, { "Type": "static class System.Net.Http.HttpResilienceHttpRequestMessageExtensions", "Stage": "Stable", @@ -287,6 +309,28 @@ } ] }, + { + "Type": "class Microsoft.Extensions.Http.Resilience.ResilienceHandler : System.Net.Http.DelegatingHandler", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.Http.Resilience.ResilienceHandler.ResilienceHandler(System.Func> pipelineProvider);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.Http.Resilience.ResilienceHandler.ResilienceHandler(Polly.ResiliencePipeline pipeline);", + "Stage": "Stable" + }, + { + "Member": "override System.Net.Http.HttpResponseMessage Microsoft.Extensions.Http.Resilience.ResilienceHandler.Send(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.Http.Resilience.ResilienceHandler.SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + } + ] + }, { "Type": "sealed class Microsoft.Extensions.Http.Resilience.ResilienceHandlerContext", "Stage": "Stable", @@ -520,4 +564,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs index aff82260365..3c1707dcac8 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs @@ -2,13 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics.CodeAnalysis; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Extensions.Http.Resilience.Internal; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using Polly; @@ -17,7 +15,6 @@ namespace Microsoft.Extensions.Http.Resilience; /// /// Base class for resilience handler, i.e. handlers that use resilience strategies to send the requests. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.Resilience, UrlFormat = DiagnosticIds.UrlFormat)] public class ResilienceHandler : DelegatingHandler { private readonly Func> _pipelineProvider; From 93413562569bde1c15713ea7d6e0abf48be662a9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 4 Dec 2024 11:41:03 +0000 Subject: [PATCH 171/190] Usage aggregation via Dictionary (#5709) --- .../AdditionalPropertiesDictionary.cs | 222 +-------------- .../AdditionalPropertiesDictionary{TValue}.cs | 258 ++++++++++++++++++ .../UsageDetails.cs | 42 ++- .../OllamaChatClient.cs | 23 +- .../OllamaEmbeddingGenerator.cs | 14 +- .../OllamaUtilities.cs | 5 +- .../OpenAIChatClient.cs | 74 +++-- .../FunctionInvokingChatClient.cs | 25 +- .../Contents/UsageContentTests.cs | 2 +- .../ChatClientIntegrationTests.cs | 19 +- .../OpenAIChatClientTests.cs | 86 ++++-- .../DistributedCachingChatClientTest.cs | 2 + .../FunctionInvokingChatClientTests.cs | 28 +- 13 files changed, 489 insertions(+), 311 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs index c780c1ccaf7..dab50ff11ee 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs @@ -1,53 +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; -using System.Collections; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using Microsoft.Shared.Diagnostics; - #pragma warning disable S1144 // Unused private types or members should be removed #pragma warning disable S2365 // Properties should not make collection or array copies #pragma warning disable S3604 // Member initializer values should not be redundant +using System.Collections.Generic; + namespace Microsoft.Extensions.AI; /// Provides a dictionary used as the AdditionalProperties dictionary on Microsoft.Extensions.AI objects. -[DebuggerTypeProxy(typeof(DebugView))] -[DebuggerDisplay("Count = {Count}")] -public sealed class AdditionalPropertiesDictionary : IDictionary, IReadOnlyDictionary +public sealed class AdditionalPropertiesDictionary : AdditionalPropertiesDictionary { - /// The underlying dictionary. - private readonly Dictionary _dictionary; - /// Initializes a new instance of the class. public AdditionalPropertiesDictionary() { - _dictionary = new(StringComparer.OrdinalIgnoreCase); } /// Initializes a new instance of the class. public AdditionalPropertiesDictionary(IDictionary dictionary) + : base(dictionary) { - _dictionary = new(dictionary, StringComparer.OrdinalIgnoreCase); } /// Initializes a new instance of the class. public AdditionalPropertiesDictionary(IEnumerable> collection) + : base(collection) { -#if NET - _dictionary = new(collection, StringComparer.OrdinalIgnoreCase); -#else - _dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var item in collection) - { - _dictionary.Add(item.Key, item.Value); - } -#endif } /// Creates a shallow clone of the properties dictionary. @@ -55,194 +34,5 @@ public AdditionalPropertiesDictionary(IEnumerable> /// A shallow clone of the properties dictionary. The instance will not be the same as the current instance, /// but it will contain all of the same key-value pairs. /// - public AdditionalPropertiesDictionary Clone() => new(_dictionary); - - /// - public object? this[string key] - { - get => _dictionary[key]; - set => _dictionary[key] = value; - } - - /// - public ICollection Keys => _dictionary.Keys; - - /// - public ICollection Values => _dictionary.Values; - - /// - public int Count => _dictionary.Count; - - /// - bool ICollection>.IsReadOnly => false; - - /// - IEnumerable IReadOnlyDictionary.Keys => _dictionary.Keys; - - /// - IEnumerable IReadOnlyDictionary.Values => _dictionary.Values; - - /// - public void Add(string key, object? value) => _dictionary.Add(key, value); - - /// Attempts to add the specified key and value to the dictionary. - /// The key of the element to add. - /// The value of the element to add. - /// if the key/value pair was added to the dictionary successfully; otherwise, . - public bool TryAdd(string key, object? value) - { -#if NET - return _dictionary.TryAdd(key, value); -#else - if (!_dictionary.ContainsKey(key)) - { - _dictionary.Add(key, value); - return true; - } - - return false; -#endif - } - - /// - void ICollection>.Add(KeyValuePair item) => ((ICollection>)_dictionary).Add(item); - - /// - public void Clear() => _dictionary.Clear(); - - /// - bool ICollection>.Contains(KeyValuePair item) => - ((ICollection>)_dictionary).Contains(item); - - /// - public bool ContainsKey(string key) => _dictionary.ContainsKey(key); - - /// - void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) => - ((ICollection>)_dictionary).CopyTo(array, arrayIndex); - - /// - /// Returns an enumerator that iterates through the . - /// - /// An that enumerates the contents of the . - public Enumerator GetEnumerator() => new(_dictionary.GetEnumerator()); - - /// - IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); - - /// - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - /// - public bool Remove(string key) => _dictionary.Remove(key); - - /// - bool ICollection>.Remove(KeyValuePair item) => ((ICollection>)_dictionary).Remove(item); - - /// - public bool TryGetValue(string key, out object? value) => _dictionary.TryGetValue(key, out value); - - /// Attempts to extract a typed value from the dictionary. - /// Specifies the type of the value to be retrieved. - /// The key to locate. - /// - /// The value retrieved from the dictionary, if found and successfully converted to the requested type; - /// otherwise, the default value of . - /// - /// - /// if a non- value was found for - /// in the dictionary and converted to the requested type; otherwise, . - /// - /// - /// If a non- value is found for the key in the dictionary, but the value is not of the requested type and is - /// an object, the method attempts to convert the object to the requested type. - /// - public bool TryGetValue(string key, [NotNullWhen(true)] out T? value) - { - if (TryGetValue(key, out object? obj)) - { - switch (obj) - { - case T t: - // The object is already of the requested type. Return it. - value = t; - return true; - - case IConvertible: - // The object is convertible; try to convert it to the requested type. Unfortunately, there's no - // convenient way to do this that avoids exceptions and that doesn't involve a ton of boilerplate, - // so we only try when the source object is at least an IConvertible, which is what ChangeType uses. - try - { - value = (T)Convert.ChangeType(obj, typeof(T), CultureInfo.InvariantCulture); - return true; - } - catch (Exception e) when (e is ArgumentException or FormatException or InvalidCastException or OverflowException) - { - // Ignore known failure modes. - } - - break; - } - } - - // Unable to find the value or convert it to the requested type. - value = default; - return false; - } - - /// Enumerates the elements of an . - public struct Enumerator : IEnumerator> - { - /// The wrapped dictionary enumerator. - private Dictionary.Enumerator _dictionaryEnumerator; - - /// Initializes a new instance of the struct with the dictionary enumerator to wrap. - /// The dictionary enumerator to wrap. - internal Enumerator(Dictionary.Enumerator dictionaryEnumerator) - { - _dictionaryEnumerator = dictionaryEnumerator; - } - - /// - public KeyValuePair Current => _dictionaryEnumerator.Current; - - /// - object IEnumerator.Current => Current; - - /// - public void Dispose() => _dictionaryEnumerator.Dispose(); - - /// - public bool MoveNext() => _dictionaryEnumerator.MoveNext(); - - /// - public void Reset() => Reset(ref _dictionaryEnumerator); - - /// Calls on an enumerator. - private static void Reset(ref TEnumerator enumerator) - where TEnumerator : struct, IEnumerator - { - enumerator.Reset(); - } - } - - /// Provides a debugger view for the collection. - private sealed class DebugView(AdditionalPropertiesDictionary properties) - { - private readonly AdditionalPropertiesDictionary _properties = Throw.IfNull(properties); - - [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] - public AdditionalProperty[] Items => (from p in _properties select new AdditionalProperty(p.Key, p.Value)).ToArray(); - - [DebuggerDisplay("{Value}", Name = "[{Key}]")] - public readonly struct AdditionalProperty(string key, object? value) - { - [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] - public string Key { get; } = key; - - [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] - public object? Value { get; } = value; - } - } + public new AdditionalPropertiesDictionary Clone() => new(this); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs new file mode 100644 index 00000000000..0c8afb3ce06 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs @@ -0,0 +1,258 @@ +// 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; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S1144 // Unused private types or members should be removed +#pragma warning disable S2365 // Properties should not make collection or array copies +#pragma warning disable S3604 // Member initializer values should not be redundant +#pragma warning disable S4039 // Interface methods should be callable by derived types +#pragma warning disable CA1033 // Interface methods should be callable by derived types + +namespace Microsoft.Extensions.AI; + +/// Provides a dictionary used as the AdditionalProperties dictionary on Microsoft.Extensions.AI objects. +/// The type of the values in the dictionary. +[DebuggerDisplay("Count = {Count}")] +[DebuggerTypeProxy(typeof(AdditionalPropertiesDictionary<>.DebugView))] +public class AdditionalPropertiesDictionary : IDictionary, IReadOnlyDictionary +{ + /// The underlying dictionary. + private readonly Dictionary _dictionary; + + /// Initializes a new instance of the class. + public AdditionalPropertiesDictionary() + { + _dictionary = new(StringComparer.OrdinalIgnoreCase); + } + + /// Initializes a new instance of the class. + public AdditionalPropertiesDictionary(IDictionary dictionary) + { + _dictionary = new(dictionary, StringComparer.OrdinalIgnoreCase); + } + + /// Initializes a new instance of the class. + public AdditionalPropertiesDictionary(IEnumerable> collection) + { +#if NET + _dictionary = new(collection, StringComparer.OrdinalIgnoreCase); +#else + _dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in collection) + { + _dictionary.Add(item.Key, item.Value); + } +#endif + } + + /// Creates a shallow clone of the properties dictionary. + /// + /// A shallow clone of the properties dictionary. The instance will not be the same as the current instance, + /// but it will contain all of the same key-value pairs. + /// + public AdditionalPropertiesDictionary Clone() => new(_dictionary); + + /// + public TValue this[string key] + { + get => _dictionary[key]; + set => _dictionary[key] = value; + } + + /// + public ICollection Keys => _dictionary.Keys; + + /// + public ICollection Values => _dictionary.Values; + + /// + public int Count => _dictionary.Count; + + /// + bool ICollection>.IsReadOnly => false; + + /// + IEnumerable IReadOnlyDictionary.Keys => _dictionary.Keys; + + /// + IEnumerable IReadOnlyDictionary.Values => _dictionary.Values; + + /// + public void Add(string key, TValue value) => _dictionary.Add(key, value); + + /// Attempts to add the specified key and value to the dictionary. + /// The key of the element to add. + /// The value of the element to add. + /// if the key/value pair was added to the dictionary successfully; otherwise, . + public bool TryAdd(string key, TValue value) + { +#if NET + return _dictionary.TryAdd(key, value); +#else + if (!_dictionary.ContainsKey(key)) + { + _dictionary.Add(key, value); + return true; + } + + return false; +#endif + } + + /// + void ICollection>.Add(KeyValuePair item) => ((ICollection>)_dictionary).Add(item); + + /// + public void Clear() => _dictionary.Clear(); + + /// + bool ICollection>.Contains(KeyValuePair item) => + ((ICollection>)_dictionary).Contains(item); + + /// + public bool ContainsKey(string key) => _dictionary.ContainsKey(key); + + /// + void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) => + ((ICollection>)_dictionary).CopyTo(array, arrayIndex); + + /// + /// Returns an enumerator that iterates through the . + /// + /// An that enumerates the contents of the . + public Enumerator GetEnumerator() => new(_dictionary.GetEnumerator()); + + /// + IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public bool Remove(string key) => _dictionary.Remove(key); + + /// + bool ICollection>.Remove(KeyValuePair item) => ((ICollection>)_dictionary).Remove(item); + + /// Attempts to extract a typed value from the dictionary. + /// Specifies the type of the value to be retrieved. + /// The key to locate. + /// + /// The value retrieved from the dictionary, if found and successfully converted to the requested type; + /// otherwise, the default value of . + /// + /// + /// if a non- value was found for + /// in the dictionary and converted to the requested type; otherwise, . + /// + /// + /// If a non- value is found for the key in the dictionary, but the value is not of the requested type and is + /// an object, the method attempts to convert the object to the requested type. + /// + public bool TryGetValue(string key, [NotNullWhen(true)] out T? value) + { + if (TryGetValue(key, out TValue? obj)) + { + switch (obj) + { + case T t: + // The object is already of the requested type. Return it. + value = t; + return true; + + case IConvertible: + // The object is convertible; try to convert it to the requested type. Unfortunately, there's no + // convenient way to do this that avoids exceptions and that doesn't involve a ton of boilerplate, + // so we only try when the source object is at least an IConvertible, which is what ChangeType uses. + try + { + value = (T)Convert.ChangeType(obj, typeof(T), CultureInfo.InvariantCulture); + return true; + } + catch (Exception e) when (e is ArgumentException or FormatException or InvalidCastException or OverflowException) + { + // Ignore known failure modes. + } + + break; + } + } + + // Unable to find the value or convert it to the requested type. + value = default; + return false; + } + + /// Gets the value associated with the specified key. + /// if the contains an element with the specified key; otherwise . + public bool TryGetValue(string key, [MaybeNullWhen(false)] out TValue value) => _dictionary.TryGetValue(key, out value); + + /// + bool IDictionary.TryGetValue(string key, out TValue value) => _dictionary.TryGetValue(key, out value!); + + /// + bool IReadOnlyDictionary.TryGetValue(string key, out TValue value) => _dictionary.TryGetValue(key, out value!); + + /// Enumerates the elements of an . + public struct Enumerator : IEnumerator> + { + /// The wrapped dictionary enumerator. + private Dictionary.Enumerator _dictionaryEnumerator; + + /// Initializes a new instance of the struct with the dictionary enumerator to wrap. + /// The dictionary enumerator to wrap. + internal Enumerator(Dictionary.Enumerator dictionaryEnumerator) + { + _dictionaryEnumerator = dictionaryEnumerator; + } + + /// + public KeyValuePair Current => _dictionaryEnumerator.Current; + + /// + object IEnumerator.Current => Current; + + /// + public void Dispose() => _dictionaryEnumerator.Dispose(); + + /// + public bool MoveNext() => _dictionaryEnumerator.MoveNext(); + + /// + public void Reset() => Reset(ref _dictionaryEnumerator); + + /// Calls on an enumerator. + private static void Reset(ref TEnumerator enumerator) + where TEnumerator : struct, IEnumerator + { + enumerator.Reset(); + } + } + + /// Provides a debugger view for the collection. + private sealed class DebugView(AdditionalPropertiesDictionary properties) + { + private readonly AdditionalPropertiesDictionary _properties = Throw.IfNull(properties); + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public AdditionalProperty[] Items => (from p in _properties select new AdditionalProperty(p.Key, p.Value)).ToArray(); + + [DebuggerDisplay("{Value}", Name = "[{Key}]")] + public readonly struct AdditionalProperty(string key, TValue value) + { + [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] + public string Key { get; } = key; + + [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] + public TValue Value { get; } = value; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs index 1e836da5045..c3b84d47bf8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs @@ -1,8 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using System.Diagnostics; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -19,8 +21,38 @@ public class UsageDetails /// Gets or sets the total number of tokens used to produce the response. public int? TotalTokenCount { get; set; } - /// Gets or sets additional properties for the usage details. - public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + /// Gets or sets a dictionary of additional usage counts. + /// + /// All values set here are assumed to be summable. For example, when middleware makes multiple calls to an underlying + /// service, it may sum the counts from multiple results to produce an overall . + /// + public AdditionalPropertiesDictionary? AdditionalCounts { get; set; } + + /// Adds usage data from another into this instance. + public void Add(UsageDetails usage) + { + _ = Throw.IfNull(usage); + InputTokenCount = NullableSum(InputTokenCount, usage.InputTokenCount); + OutputTokenCount = NullableSum(OutputTokenCount, usage.OutputTokenCount); + TotalTokenCount = NullableSum(TotalTokenCount, usage.TotalTokenCount); + + if (usage.AdditionalCounts is { } countsToAdd) + { + if (AdditionalCounts is null) + { + AdditionalCounts = new(countsToAdd); + } + else + { + foreach (var kvp in countsToAdd) + { + AdditionalCounts[kvp.Key] = AdditionalCounts.TryGetValue(kvp.Key, out var existingValue) ? + kvp.Value + existingValue : + kvp.Value; + } + } + } + } /// Gets a string representing this instance to display in the debugger. [DebuggerBrowsable(DebuggerBrowsableState.Never)] @@ -45,9 +77,9 @@ internal string DebuggerDisplay parts.Add($"{nameof(TotalTokenCount)} = {total}"); } - if (AdditionalProperties is { } additionalProperties) + if (AdditionalCounts is { } additionalCounts) { - foreach (var entry in additionalProperties) + foreach (var entry in additionalCounts) { parts.Add($"{entry.Key} = {entry.Value}"); } @@ -56,4 +88,6 @@ internal string DebuggerDisplay return string.Join(", ", parts); } } + + private static int? NullableSum(int? a, int? b) => (a.HasValue || b.HasValue) ? (a ?? 0) + (b ?? 0) : null; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index abfa3f2b203..4f923434e3a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -100,7 +100,6 @@ public async Task CompleteAsync(IList chatMessages, CompletionId = response.CreatedAt, ModelId = response.Model ?? options?.ModelId ?? Metadata.ModelId, CreatedAt = DateTimeOffset.TryParse(response.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, - AdditionalProperties = ParseOllamaChatResponseProps(response), FinishReason = ToFinishReason(response), Usage = ParseOllamaChatResponseUsage(response), }; @@ -153,7 +152,6 @@ public async IAsyncEnumerable CompleteStreamingAs { Role = chunk.Message?.Role is not null ? new ChatRole(chunk.Message.Role) : null, CreatedAt = DateTimeOffset.TryParse(chunk.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, - AdditionalProperties = ParseOllamaChatResponseProps(chunk), FinishReason = ToFinishReason(chunk), ModelId = modelId, }; @@ -193,31 +191,26 @@ public void Dispose() private static UsageDetails? ParseOllamaChatResponseUsage(OllamaChatResponse response) { - if (response.PromptEvalCount is not null || response.EvalCount is not null) + 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 AdditionalPropertiesDictionary? ParseOllamaChatResponseProps(OllamaChatResponse response) - { - AdditionalPropertiesDictionary? metadata = null; - - OllamaUtilities.TransferNanosecondsTime(response, static r => r.LoadDuration, "load_duration", ref metadata); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.TotalDuration, "total_duration", ref metadata); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.PromptEvalDuration, "prompt_eval_duration", ref metadata); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.EvalDuration, "eval_duration", ref metadata); - - return metadata; - } - private static ChatFinishReason? ToFinishReason(OllamaChatResponse response) => response.DoneReason switch { diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs index 288971d3534..5377b5f7092 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs @@ -126,17 +126,18 @@ public async Task>> GenerateAsync( } // Convert response into result objects. - AdditionalPropertiesDictionary? responseProps = null; - OllamaUtilities.TransferNanosecondsTime(response, r => r.TotalDuration, "total_duration", ref responseProps); - OllamaUtilities.TransferNanosecondsTime(response, r => r.LoadDuration, "load_duration", ref responseProps); + 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 (response.PromptEvalCount is int tokens) + if (additionalCounts is not null || response.PromptEvalCount is not null) { usage = new() { - InputTokenCount = tokens, - TotalTokenCount = tokens, + InputTokenCount = response.PromptEvalCount, + TotalTokenCount = response.PromptEvalCount, + AdditionalCounts = additionalCounts, }; } @@ -148,7 +149,6 @@ public async Task>> GenerateAsync( })) { Usage = usage, - AdditionalProperties = responseProps, }; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs index ba823cde7f8..d7db10e5a04 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs @@ -17,14 +17,13 @@ internal static class OllamaUtilities Timeout = Timeout.InfiniteTimeSpan, }; - public static void TransferNanosecondsTime(TResponse response, Func getNanoseconds, string key, ref AdditionalPropertiesDictionary? metadata) + public static void TransferNanosecondsTime(TResponse response, Func getNanoseconds, string key, ref AdditionalPropertiesDictionary? metadata) { if (getNanoseconds(response) is long duration) { try { - const double NanosecondsPerMillisecond = 1_000_000; - (metadata ??= [])[key] = TimeSpan.FromMilliseconds(duration / NanosecondsPerMillisecond); + (metadata ??= [])[key] = duration; } catch (OverflowException) { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 05bd801ac09..cf8daec75ef 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -159,17 +159,7 @@ public async Task CompleteAsync( if (response.Usage is ChatTokenUsage tokenUsage) { - completion.Usage = new() - { - InputTokenCount = tokenUsage.InputTokenCount, - OutputTokenCount = tokenUsage.OutputTokenCount, - TotalTokenCount = tokenUsage.TotalTokenCount, - }; - - if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails details) - { - completion.Usage.AdditionalProperties = new() { [nameof(details.ReasoningTokenCount)] = details.ReasoningTokenCount }; - } + completion.Usage = ToUsageDetails(tokenUsage); } if (response.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) @@ -290,23 +280,7 @@ public async IAsyncEnumerable CompleteStreamingAs // Transfer over usage updates. if (chatCompletionUpdate.Usage is ChatTokenUsage tokenUsage) { - UsageDetails usageDetails = new() - { - InputTokenCount = tokenUsage.InputTokenCount, - OutputTokenCount = tokenUsage.OutputTokenCount, - TotalTokenCount = tokenUsage.TotalTokenCount, - }; - - if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails details) - { - (usageDetails.AdditionalProperties = [])[nameof(tokenUsage.OutputTokenDetails)] = new Dictionary - { - [nameof(details.ReasoningTokenCount)] = details.ReasoningTokenCount, - }; - } - - // TODO: Add support for prompt token details (e.g. cached tokens) once it's exposed in OpenAI library. - + var usageDetails = ToUsageDetails(tokenUsage); completionUpdate.Contents.Add(new UsageContent(usageDetails)); } @@ -370,6 +344,50 @@ private sealed class FunctionCallInfo public StringBuilder? Arguments; } + private static UsageDetails ToUsageDetails(ChatTokenUsage tokenUsage) + { + var destination = new UsageDetails + { + InputTokenCount = tokenUsage.InputTokenCount, + OutputTokenCount = tokenUsage.OutputTokenCount, + TotalTokenCount = tokenUsage.TotalTokenCount, + AdditionalCounts = new(), + }; + + if (tokenUsage.InputTokenDetails is ChatInputTokenUsageDetails inputDetails) + { + if (inputDetails.AudioTokenCount is int audioTokenCount) + { + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", + audioTokenCount); + } + + if (inputDetails.CachedTokenCount is int cachedTokenCount) + { + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", + cachedTokenCount); + } + } + + if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails outputDetails) + { + if (outputDetails.AudioTokenCount is int audioTokenCount) + { + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", + audioTokenCount); + } + + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)}", + outputDetails.ReasoningTokenCount); + } + + return destination; + } + /// Converts an OpenAI role to an Extensions role. private static ChatRole ToChatRole(ChatMessageRole role) => role switch diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 20c70eb05d4..a7a209e2586 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -190,10 +190,12 @@ public int? MaximumIterationsPerRequest public override async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(chatMessages); - ChatCompletion? response; + ChatCompletion? response = null; HashSet? messagesToRemove = null; HashSet? contentsToRemove = null; + UsageDetails? totalUsage = null; + try { for (int iteration = 0; ; iteration++) @@ -201,6 +203,13 @@ public override async Task CompleteAsync(IList chat // Make the call to the handler. response = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false); + // Aggregate usage data over all calls + if (response.Usage is not null) + { + totalUsage ??= new(); + totalUsage.Add(response.Usage); + } + // If there are no tools to call, or for any other reason we should stop, return the response. if (options is null || options.Tools is not { Count: > 0 } @@ -252,13 +261,6 @@ public override async Task CompleteAsync(IList chat } } - // If the original chat completion included usage data, - // add that into the message so it's available in the history. - if (KeepFunctionCallingMessages && response.Usage is { } usage) - { - response.Message.Contents = [.. response.Message.Contents, new UsageContent(usage)]; - } - // Add the responses from the function calls into the history. var modeAndMessages = await ProcessFunctionCallsAsync(chatMessages, options, functionCallContents, iteration, cancellationToken).ConfigureAwait(false); if (modeAndMessages.MessagesAdded is not null) @@ -286,11 +288,16 @@ public override async Task CompleteAsync(IList chat } } - return response!; + return response; } finally { RemoveMessagesAndContentFromList(messagesToRemove, contentsToRemove, chatMessages); + + if (response is not null) + { + response.Usage = totalUsage; + } } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs index 2314cd66f93..514e2defecf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs @@ -26,7 +26,7 @@ public void Constructor_Parameterless_PropsDefault() Assert.Null(c.Details.InputTokenCount); Assert.Null(c.Details.OutputTokenCount); Assert.Null(c.Details.TotalTokenCount); - Assert.Null(c.Details.AdditionalProperties); + Assert.Null(c.Details.AdditionalCounts); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 818aba7a97b..8bff6a01bd3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -161,7 +161,15 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_Paramet { SkipIfNotEnabled(); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + 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)); int secretNumber = 42; @@ -178,11 +186,14 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_Paramet Assert.Single(response.Choices); Assert.Contains(secretNumber.ToString(), response.Message.Text); + // If the underlying IChatClient provides usage data, function invocation should aggregate the + // usage data across all calls to produce a single Usage value on the final response if (response.Usage is { } finalUsage) { - UsageContent? intermediate = messages.SelectMany(m => m.Contents).OfType().FirstOrDefault(); - Assert.NotNull(intermediate); - Assert.True(finalUsage.TotalTokenCount > intermediate.Details.TotalTokenCount); + var totalInputTokens = activities.Sum(a => (int?)a.GetTagItem("gen_ai.response.input_tokens")!); + var totalOutputTokens = activities.Sum(a => (int?)a.GetTagItem("gen_ai.response.output_tokens")!); + Assert.Equal(totalInputTokens, finalUsage.InputTokenCount); + Assert.Equal(totalOutputTokens, finalUsage.OutputTokenCount); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 982df50a707..986ebefd518 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -166,10 +166,10 @@ public async Task BasicRequestResponse_NonStreaming() "completion_tokens": 9, "total_tokens": 17, "prompt_tokens_details": { - "cached_tokens": 0 + "cached_tokens": 13 }, "completion_tokens_details": { - "reasoning_tokens": 0 + "reasoning_tokens": 90 } }, "system_fingerprint": "fp_f85bea6784" @@ -199,7 +199,11 @@ public async Task BasicRequestResponse_NonStreaming() Assert.Equal(8, response.Usage.InputTokenCount); Assert.Equal(9, response.Usage.OutputTokenCount); Assert.Equal(17, response.Usage.TotalTokenCount); - Assert.NotNull(response.Usage.AdditionalProperties); + Assert.Equal(new Dictionary + { + { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.ReasoningTokenCount", 90 } + }, response.Usage.AdditionalCounts); Assert.NotNull(response.AdditionalProperties); Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); @@ -235,7 +239,7 @@ public async Task BasicRequestResponse_Streaming() data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} - data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":8,"completion_tokens":9,"total_tokens":17,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}} + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":8,"completion_tokens":9,"total_tokens":17,"prompt_tokens_details":{"cached_tokens":5,"audio_tokens":123},"completion_tokens_details":{"reasoning_tokens":90,"audio_tokens":456}}} data: [DONE] @@ -275,8 +279,14 @@ public async Task BasicRequestResponse_Streaming() Assert.Equal(8, usage.Details.InputTokenCount); Assert.Equal(9, usage.Details.OutputTokenCount); Assert.Equal(17, usage.Details.TotalTokenCount); - Assert.NotNull(usage.Details.AdditionalProperties); - Assert.Equal(new Dictionary { [nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)] = 0 }, usage.Details.AdditionalProperties[nameof(ChatTokenUsage.OutputTokenDetails)]); + + Assert.Equal(new AdditionalPropertiesDictionary + { + { "InputTokenDetails.AudioTokenCount", 123 }, + { "InputTokenDetails.CachedTokenCount", 5 }, + { "OutputTokenDetails.AudioTokenCount", 456 }, + { "OutputTokenDetails.ReasoningTokenCount", 90 }, + }, usage.Details.AdditionalCounts); } [Fact] @@ -336,10 +346,12 @@ public async Task MultipleMessages_NonStreaming() "completion_tokens": 15, "total_tokens": 57, "prompt_tokens_details": { - "cached_tokens": 0 + "cached_tokens": 13, + "audio_tokens": 123 }, "completion_tokens_details": { - "reasoning_tokens": 0 + "reasoning_tokens": 90, + "audio_tokens": 456 } }, "system_fingerprint": "fp_f85bea6784" @@ -380,7 +392,13 @@ public async Task MultipleMessages_NonStreaming() Assert.Equal(42, response.Usage.InputTokenCount); Assert.Equal(15, response.Usage.OutputTokenCount); Assert.Equal(57, response.Usage.TotalTokenCount); - Assert.NotNull(response.Usage.AdditionalProperties); + Assert.Equal(new Dictionary + { + { "InputTokenDetails.AudioTokenCount", 123 }, + { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.AudioTokenCount", 456 }, + { "OutputTokenDetails.ReasoningTokenCount", 90 }, + }, response.Usage.AdditionalCounts); Assert.NotNull(response.AdditionalProperties); Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); @@ -437,10 +455,10 @@ public async Task MultiPartSystemMessage_NonStreaming() "completion_tokens": 15, "total_tokens": 57, "prompt_tokens_details": { - "cached_tokens": 0 + "cached_tokens": 13 }, "completion_tokens_details": { - "reasoning_tokens": 0 + "reasoning_tokens": 90 } }, "system_fingerprint": "fp_f85bea6784" @@ -472,7 +490,11 @@ public async Task MultiPartSystemMessage_NonStreaming() Assert.Equal(42, response.Usage.InputTokenCount); Assert.Equal(15, response.Usage.OutputTokenCount); Assert.Equal(57, response.Usage.TotalTokenCount); - Assert.NotNull(response.Usage.AdditionalProperties); + Assert.Equal(new Dictionary + { + { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.ReasoningTokenCount", 90 } + }, response.Usage.AdditionalCounts); Assert.NotNull(response.AdditionalProperties); Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); @@ -528,10 +550,10 @@ public async Task EmptyAssistantMessage_NonStreaming() "completion_tokens": 15, "total_tokens": 57, "prompt_tokens_details": { - "cached_tokens": 0 + "cached_tokens": 13 }, "completion_tokens_details": { - "reasoning_tokens": 0 + "reasoning_tokens": 90 } }, "system_fingerprint": "fp_f85bea6784" @@ -565,7 +587,11 @@ public async Task EmptyAssistantMessage_NonStreaming() Assert.Equal(42, response.Usage.InputTokenCount); Assert.Equal(15, response.Usage.OutputTokenCount); Assert.Equal(57, response.Usage.TotalTokenCount); - Assert.NotNull(response.Usage.AdditionalProperties); + Assert.Equal(new Dictionary + { + { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.ReasoningTokenCount", 90 } + }, response.Usage.AdditionalCounts); Assert.NotNull(response.AdditionalProperties); Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); @@ -641,10 +667,10 @@ public async Task FunctionCallContent_NonStreaming() "completion_tokens": 16, "total_tokens": 77, "prompt_tokens_details": { - "cached_tokens": 0 + "cached_tokens": 13 }, "completion_tokens_details": { - "reasoning_tokens": 0 + "reasoning_tokens": 90 } }, "system_fingerprint": "fp_f85bea6784" @@ -671,6 +697,12 @@ public async Task FunctionCallContent_NonStreaming() Assert.Equal(16, response.Usage.OutputTokenCount); Assert.Equal(77, response.Usage.TotalTokenCount); + Assert.Equal(new Dictionary + { + { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.ReasoningTokenCount", 90 } + }, response.Usage.AdditionalCounts); + Assert.Single(response.Choices); Assert.Single(response.Message.Contents); FunctionCallContent fcc = Assert.IsType(response.Message.Contents[0]); @@ -739,7 +771,7 @@ public async Task FunctionCallContent_Streaming() data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":null} - data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":61,"completion_tokens":16,"total_tokens":77,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":0}}} + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","object":"chat.completion.chunk","created":1727895263,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":61,"completion_tokens":16,"total_tokens":77,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":90}}} data: [DONE] @@ -782,8 +814,12 @@ public async Task FunctionCallContent_Streaming() Assert.Equal(61, usage.Details.InputTokenCount); Assert.Equal(16, usage.Details.OutputTokenCount); Assert.Equal(77, usage.Details.TotalTokenCount); - Assert.NotNull(usage.Details.AdditionalProperties); - Assert.Equal(new Dictionary { [nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)] = 0 }, usage.Details.AdditionalProperties[nameof(ChatTokenUsage.OutputTokenDetails)]); + + Assert.Equal(new Dictionary + { + { "InputTokenDetails.CachedTokenCount", 0 }, + { "OutputTokenDetails.ReasoningTokenCount", 90 } + }, usage.Details.AdditionalCounts); } [Fact] @@ -868,10 +904,10 @@ public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() "completion_tokens": 15, "total_tokens": 57, "prompt_tokens_details": { - "cached_tokens": 0 + "cached_tokens": 20 }, "completion_tokens_details": { - "reasoning_tokens": 0 + "reasoning_tokens": 90 } }, "system_fingerprint": "fp_f85bea6784" @@ -916,7 +952,11 @@ public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() Assert.Equal(42, response.Usage.InputTokenCount); Assert.Equal(15, response.Usage.OutputTokenCount); Assert.Equal(57, response.Usage.TotalTokenCount); - Assert.NotNull(response.Usage.AdditionalProperties); + Assert.Equal(new Dictionary + { + { "InputTokenDetails.CachedTokenCount", 20 }, + { "OutputTokenDetails.ReasoningTokenCount", 90 } + }, response.Usage.AdditionalCounts); Assert.NotNull(response.AdditionalProperties); Assert.Equal("fp_f85bea6784", response.AdditionalProperties[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)]); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs index d144c966f39..f66ce1cbd5b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs @@ -61,6 +61,7 @@ public async Task CachesSuccessResultsAsync() InputTokenCount = 123, OutputTokenCount = 456, TotalTokenCount = 99999, + AdditionalCounts = new() { ["someValue"] = 1_234_567 } }, CreatedAt = DateTimeOffset.UtcNow, ModelId = "someModel", @@ -732,6 +733,7 @@ private static void AssertCompletionsEqual(ChatCompletion expected, ChatCompleti Assert.Equal(expected.Usage?.InputTokenCount, actual.Usage?.InputTokenCount); Assert.Equal(expected.Usage?.OutputTokenCount, actual.Usage?.OutputTokenCount); Assert.Equal(expected.Usage?.TotalTokenCount, actual.Usage?.TotalTokenCount); + Assert.Equal(expected.Usage?.AdditionalCounts, actual.Usage?.AdditionalCounts); Assert.Equal(expected.CreatedAt, actual.CreatedAt); Assert.Equal(expected.ModelId, actual.ModelId); Assert.Equal( diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index a274c6225a7..86369edbc75 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -559,6 +559,7 @@ private static async Task> InvokeAndAssertAsync( using CancellationTokenSource cts = new(); List chat = [plan[0]]; + var expectedTotalTokenCounts = 0; using var innerClient = new TestChatClient { @@ -569,7 +570,9 @@ private static async Task> InvokeAndAssertAsync( await Task.Yield(); - return new ChatCompletion(new ChatMessage(ChatRole.Assistant, [.. plan[contents.Count].Contents])); + var usage = CreateRandomUsage(); + expectedTotalTokenCounts += usage.InputTokenCount!.Value; + return new ChatCompletion(new ChatMessage(ChatRole.Assistant, [.. plan[contents.Count].Contents])) { Usage = usage }; } }; @@ -612,9 +615,32 @@ private static async Task> InvokeAndAssertAsync( } } + // Usage should be aggregated over all responses, including AdditionalUsage + var actualUsage = result.Usage!; + Assert.Equal(expectedTotalTokenCounts, actualUsage.InputTokenCount); + Assert.Equal(expectedTotalTokenCounts, actualUsage.OutputTokenCount); + Assert.Equal(expectedTotalTokenCounts, actualUsage.TotalTokenCount); + Assert.Equal(2, actualUsage.AdditionalCounts!.Count); + Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["firstValue"]); + Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["secondValue"]); + return chat; } + private static UsageDetails CreateRandomUsage() + { + // We'll set the same random number on all the properties so that, when determining the + // correct sum in tests, we only have to total the values once + var value = new Random().Next(100); + return new UsageDetails + { + InputTokenCount = value, + OutputTokenCount = value, + TotalTokenCount = value, + AdditionalCounts = new() { ["firstValue"] = value, ["secondValue"] = value }, + }; + } + private static async Task> InvokeAndAssertStreamingAsync( ChatOptions options, List plan, From a23a7a94fbea06d583d0bac0dbbfba981b5a31ff Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 4 Dec 2024 10:29:37 -0500 Subject: [PATCH 172/190] Update otel chat client / embedding generator for 1.29 draft of the spec (#5712) * Update otel chat client / embedding generator for 1.29 Also address feedback to include additional properties as tags. * Increase code coverage * Bump Microsoft.Extensions.AI.Abstractions min coverage --- .../ChatCompletion/ChatClientMetadata.cs | 14 ++- ...StreamingChatCompletionUpdateExtensions.cs | 16 ++++ .../Embeddings/EmbeddingGeneratorMetadata.cs | 19 +++- ...icrosoft.Extensions.AI.Abstractions.csproj | 2 +- ...soft.Extensions.AI.AzureAIInference.csproj | 2 +- .../ChatCompletion/OpenTelemetryChatClient.cs | 31 ++++++- .../OpenTelemetryEmbeddingGenerator.cs | 44 ++++++++- .../Microsoft.Extensions.AI.csproj | 2 +- .../OpenTelemetryConsts.cs | 4 +- .../TestEmbeddingGenerator.cs | 2 +- .../ChatCompletion/ChatClientBuilderTest.cs | 18 ++++ .../OpenTelemetryChatClientTests.cs | 23 ++++- .../OpenTelemetryEmbeddingGeneratorTests.cs | 91 +++++++++++++++++++ 13 files changed, 252 insertions(+), 16 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs index d21d3b20585..406b9768dd7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatClientMetadata.cs @@ -9,7 +9,10 @@ namespace Microsoft.Extensions.AI; public class ChatClientMetadata { /// Initializes a new instance of the class. - /// The name of the chat completion provider, if applicable. + /// + /// The name of the chat completion provider, if applicable. Where possible, this should map to the + /// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems. + /// /// The URL for accessing the chat completion provider, if applicable. /// The ID of the chat completion model used, if applicable. public ChatClientMetadata(string? providerName = null, Uri? providerUri = null, string? modelId = null) @@ -20,12 +23,19 @@ public ChatClientMetadata(string? providerName = null, Uri? providerUri = null, } /// Gets the name of the chat completion provider. + /// + /// Where possible, this maps to the appropriate name defined in the + /// OpenTelemetry Semantic Conventions for Generative AI systems. + /// public string? ProviderName { get; } /// Gets the URL for accessing the chat completion provider. public Uri? ProviderUri { get; } /// Gets the ID of the model used by this chat completion provider. - /// This value can be null if either the name is unknown or there are multiple possible models associated with this instance. + /// + /// This value can be null if either the name is unknown or there are multiple possible models associated with this instance. + /// An individual request may override this value via . + /// public string? ModelId { get; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs index 928b9366a27..b70d7471b80 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/StreamingChatCompletionUpdateExtensions.cs @@ -136,17 +136,33 @@ private static void AddMessagesToCompletion(Dictionary message { if (messages.Count <= 1) { + // Add the single message if there is one. foreach (var entry in messages) { AddMessage(completion, coalesceContent, entry); } + + // In the vast majority case where there's only one choice, promote any additional properties + // from the single message to the chat completion, making them more discoverable and more similar + // to how they're typically surfaced from non-streaming services. + if (completion.Choices.Count == 1 && + completion.Choices[0].AdditionalProperties is { } messageProps) + { + completion.Choices[0].AdditionalProperties = null; + completion.AdditionalProperties = messageProps; + } } else { + // Add all of the messages, sorted by choice index. foreach (var entry in messages.OrderBy(entry => entry.Key)) { AddMessage(completion, coalesceContent, entry); } + + // If there are multiple choices, we don't promote additional properties from the individual messages. + // At a minimum, we'd want to know which choice the additional properties applied to, and if there were + // conflicting values across the choices, it would be unclear which one should be used. } static void AddMessage(ChatCompletion completion, bool coalesceContent, KeyValuePair entry) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs index 0f2f7b23af5..a3f5181648b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorMetadata.cs @@ -9,7 +9,11 @@ namespace Microsoft.Extensions.AI; public class EmbeddingGeneratorMetadata { /// Initializes a new instance of the class. - /// The name of the embedding generation provider, if applicable. + + /// + /// The name of the embedding generation provider, if applicable. Where possible, this should map to the + /// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems. + /// /// The URL for accessing the embedding generation provider, if applicable. /// The ID of the embedding generation model used, if applicable. /// The number of dimensions in vectors produced by this generator, if applicable. @@ -22,15 +26,26 @@ public EmbeddingGeneratorMetadata(string? providerName = null, Uri? providerUri } /// Gets the name of the embedding generation provider. + /// + /// Where possible, this maps to the appropriate name defined in the + /// OpenTelemetry Semantic Conventions for Generative AI systems. + /// public string? ProviderName { get; } /// Gets the URL for accessing the embedding generation provider. public Uri? ProviderUri { get; } /// Gets the ID of the model used by this embedding generation provider. - /// This value can be null if either the name is unknown or there are multiple possible models associated with this instance. + /// + /// This value can be null if either the name is unknown or there are multiple possible models associated with this instance. + /// An individual request may override this value via . + /// public string? ModelId { get; } /// Gets the number of dimensions in the embeddings produced by this instance. + /// + /// This value can be null if either the number of dimensions is unknown or there are multiple possible lengths associated with this instance. + /// An individual request may override this value via . + /// public int? Dimensions { get; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index 4d7e314a0e4..756ec27adc4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -9,7 +9,7 @@ preview true - 83 + 84 0 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 6896a186d0a..919fa9b751f 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 @@ -9,7 +9,7 @@ preview true - 83 + 91 0 diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index 193006780a2..9da805932f2 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -23,7 +23,7 @@ namespace Microsoft.Extensions.AI; /// Represents a delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// -/// The draft specification this follows is available at . +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.29, 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 @@ -288,6 +288,19 @@ public override async IAsyncEnumerable CompleteSt { _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.PerProvider(_system, "seed"), seed); } + + if (options.AdditionalProperties is { } props) + { + // Log all additional request options as per-provider tags. This is non-normative, but it covers cases where + // there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.request.service_tier), + // and more generally cases where there's additional useful information to be logged. + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Request.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), + prop.Value); + } + } } } } @@ -375,6 +388,22 @@ private void TraceCompletion( { _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.OutputTokens, outputTokens); } + + if (_system is not null) + { + // Log all additional response properties as per-provider tags. This is non-normative, but it covers cases where + // there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.response.system_fingerprint), + // and more generally cases where there's additional useful information to be logged. + if (completion.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Response.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), + prop.Value); + } + } + } } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs index 09f762d33d0..8bb38bf2e07 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Diagnostics.Metrics; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -15,8 +16,8 @@ namespace Microsoft.Extensions.AI; /// Represents a delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// -/// The draft specification this follows is available at . -/// The specification is still experimental and subject to change; as such, the telemetry output by this generator is also subject to change. +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.29, 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. /// The type of embedding generated. @@ -29,6 +30,7 @@ public sealed class OpenTelemetryEmbeddingGenerator : Delega private readonly Histogram _tokenUsageHistogram; private readonly Histogram _operationDurationHistogram; + private readonly string? _system; private readonly string? _modelId; private readonly string? _modelProvider; private readonly string? _endpointAddress; @@ -49,6 +51,7 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator i Debug.Assert(innerGenerator is not null, "Should have been validated by the base ctor."); EmbeddingGeneratorMetadata metadata = innerGenerator!.Metadata; + _system = metadata.ProviderName; _modelId = metadata.ModelId; _modelProvider = metadata.ProviderName; _endpointAddress = metadata.ProviderUri?.GetLeftPart(UriPartial.Path); @@ -126,11 +129,11 @@ protected override void Dispose(bool disposing) string? modelId = options?.ModelId ?? _modelId; activity = _activitySource.StartActivity( - string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Embed : $"{OpenTelemetryConsts.GenAI.Embed} {modelId}", + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Embeddings : $"{OpenTelemetryConsts.GenAI.Embeddings} {modelId}", ActivityKind.Client, default(ActivityContext), [ - new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embed), + new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embeddings), new(OpenTelemetryConsts.GenAI.Request.Model, modelId), new(OpenTelemetryConsts.GenAI.SystemName, _modelProvider), ]); @@ -148,6 +151,23 @@ protected override void Dispose(bool disposing) { _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.EmbeddingDimensions, dimensions); } + + if (options is not null && + _system is not null) + { + // Log all additional request options as per-provider tags. This is non-normative, but it covers cases where + // there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.request.service_tier), + // and more generally cases where there's additional useful information to be logged. + if (options.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Request.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), + prop.Value); + } + } + } } } @@ -212,12 +232,26 @@ private void TraceCompletion( { _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Model, responseModelId); } + + // Log all additional response properties as per-provider tags. This is non-normative, but it covers cases where + // there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.response.system_fingerprint), + // and more generally cases where there's additional useful information to be logged. + if (_system is not null && + embeddings?.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Response.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), + prop.Value); + } + } } } private void AddMetricTags(ref TagList tags, string? requestModelId, string? responseModelId) { - tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embed); + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embeddings); if (requestModelId is not null) { diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index a3bed483c44..7bb57e95a3b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -11,7 +11,7 @@ preview true - 83 + 88 0 diff --git a/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs index 27a543705ba..4c40c04c236 100644 --- a/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs +++ b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs @@ -30,7 +30,7 @@ public static class GenAI public const string SystemName = "gen_ai.system"; public const string Chat = "chat"; - public const string Embed = "embed"; + public const string Embeddings = "embeddings"; public static class Assistant { @@ -81,6 +81,8 @@ public static class Response public const string InputTokens = "gen_ai.response.input_tokens"; public const string Model = "gen_ai.response.model"; public const string OutputTokens = "gen_ai.response.output_tokens"; + + public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.response.{parameterName}"; } public static class System diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs index fd85eb52391..3908a5f8cca 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs @@ -15,7 +15,7 @@ public TestEmbeddingGenerator() GetServiceCallback = DefaultGetServiceCallback; } - public EmbeddingGeneratorMetadata Metadata { get; } = new(); + public EmbeddingGeneratorMetadata Metadata { get; set; } = new(); public Func, EmbeddingGenerationOptions?, CancellationToken, Task>>>? GenerateAsyncCallback { get; set; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs index c9d09db9836..c39c6b8c2b7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientBuilderTest.cs @@ -78,6 +78,24 @@ public void DoesNotAllowFactoriesToReturnNull() Assert.Contains("entry at index 0", ex.Message); } + [Fact] + public void UsesEmptyServiceProviderWhenNoServicesProvided() + { + using var innerClient = new TestChatClient(); + ChatClientBuilder builder = new(innerClient); + builder.Use((innerClient, serviceProvider) => + { + Assert.Null(serviceProvider.GetService(typeof(object))); + + var keyedServiceProvider = Assert.IsAssignableFrom(serviceProvider); + Assert.Null(keyedServiceProvider.GetKeyedService(typeof(object), "key")); + Assert.Throws(() => keyedServiceProvider.GetRequiredKeyedService(typeof(object), "key")); + + return innerClient; + }); + builder.Build(); + } + private sealed class InnerClientCapturingChatClient(string name, IChatClient innerClient) : DelegatingChatClient(innerClient) { #pragma warning disable S3604 // False positive: Member initializer values should not be redundant diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs index 3d7d05f981a..1d99cb60731 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs @@ -49,6 +49,11 @@ public async Task ExpectedInformationLogged_Async(bool enableSensitiveData, bool OutputTokenCount = 20, TotalTokenCount = 42, }, + AdditionalProperties = new() + { + ["system_fingerprint"] = "abcdefgh", + ["AndSomethingElse"] = "value2", + }, }; }, CompleteStreamingAsyncCallback = CallbackAsync, @@ -83,10 +88,15 @@ async static IAsyncEnumerable CallbackAsync( OutputTokenCount = 20, TotalTokenCount = 42, })], + AdditionalProperties = new() + { + ["system_fingerprint"] = "abcdefgh", + ["AndSomethingElse"] = "value2", + }, }; } - var chatClient = innerClient + using var chatClient = innerClient .AsBuilder() .UseOpenTelemetry(loggerFactory, sourceName, configure: instance => { @@ -115,7 +125,13 @@ async static IAsyncEnumerable CallbackAsync( PresencePenalty = 5.0f, ResponseFormat = ChatResponseFormat.Json, Temperature = 6.0f, + Seed = 42, StopSequences = ["hello", "world"], + AdditionalProperties = new() + { + ["service_tier"] = "value1", + ["SomethingElse"] = "value2", + }, }; if (streaming) @@ -149,11 +165,16 @@ async static IAsyncEnumerable CallbackAsync( Assert.Equal(7, activity.GetTagItem("gen_ai.request.top_k")); Assert.Equal(123, activity.GetTagItem("gen_ai.request.max_tokens")); Assert.Equal("""["hello", "world"]""", activity.GetTagItem("gen_ai.request.stop_sequences")); + Assert.Equal("value1", activity.GetTagItem("gen_ai.testservice.request.service_tier")); + Assert.Equal("value2", activity.GetTagItem("gen_ai.testservice.request.something_else")); + Assert.Equal(42L, activity.GetTagItem("gen_ai.testservice.request.seed")); Assert.Equal("id123", activity.GetTagItem("gen_ai.response.id")); Assert.Equal("""["stop"]""", activity.GetTagItem("gen_ai.response.finish_reasons")); Assert.Equal(10, activity.GetTagItem("gen_ai.response.input_tokens")); Assert.Equal(20, activity.GetTagItem("gen_ai.response.output_tokens")); + Assert.Equal("abcdefgh", activity.GetTagItem("gen_ai.testservice.response.system_fingerprint")); + Assert.Equal("value2", activity.GetTagItem("gen_ai.testservice.response.and_something_else")); Assert.True(activity.Duration.TotalMilliseconds > 0); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs new file mode 100644 index 00000000000..e5dc014d6aa --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using OpenTelemetry.Trace; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenTelemetryEmbeddingGeneratorTests +{ + [Fact] + public async Task ExpectedInformationLogged_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + + using var innerGenerator = new TestEmbeddingGenerator + { + Metadata = new("testservice", new Uri("http://localhost:12345/something"), "amazingmodel", 384), + GenerateAsyncCallback = async (values, options, cancellationToken) => + { + await Task.Yield(); + return new GeneratedEmbeddings>([new Embedding(new float[] { 1, 2, 3 })]) + { + Usage = new() + { + InputTokenCount = 10, + TotalTokenCount = 10, + }, + AdditionalProperties = new() + { + ["system_fingerprint"] = "abcdefgh", + ["AndSomethingElse"] = "value2", + } + }; + }, + }; + + using var generator = innerGenerator + .AsBuilder() + .UseOpenTelemetry(loggerFactory, sourceName) + .Build(); + + var options = new EmbeddingGenerationOptions + { + ModelId = "replacementmodel", + AdditionalProperties = new() + { + ["service_tier"] = "value1", + ["SomethingElse"] = "value2", + }, + }; + + await generator.GenerateEmbeddingVectorAsync("hello", options); + + var activity = Assert.Single(activities); + + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + + Assert.Equal("http://localhost:12345/something", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); + + Assert.Equal("embeddings replacementmodel", activity.DisplayName); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.system")); + + Assert.Equal("replacementmodel", activity.GetTagItem("gen_ai.request.model")); + Assert.Equal("value1", activity.GetTagItem("gen_ai.testservice.request.service_tier")); + Assert.Equal("value2", activity.GetTagItem("gen_ai.testservice.request.something_else")); + + Assert.Equal(10, activity.GetTagItem("gen_ai.response.input_tokens")); + Assert.Equal("abcdefgh", activity.GetTagItem("gen_ai.testservice.response.system_fingerprint")); + Assert.Equal("value2", activity.GetTagItem("gen_ai.testservice.response.and_something_else")); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + } +} From af32f59fc6de216c2ca4cd0f0fa27e4713e0aa74 Mon Sep 17 00:00:00 2001 From: Dmytro Bohdanov <41544793+rainsxng@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:46:57 +0100 Subject: [PATCH 173/190] Additional tests to the logging generator (#5704) * TypeSymbolExtensions tests * Added explaining comment * Update test cases * Add tests for the IConvertable and ISpanFormattable interfaces * Attempt to test the special types * Check special types * HasCustomToString tests * Extra InlineData for the tests * GetPossiblyNullWrappedType tests * Fixed a typo * Simplify HasCustomToString tests * Updated test cases * Fix lint * Removed extra method override from the tests --------- Co-authored-by: Darius Letterman Co-authored-by: Igor Velikorossov --- .../Unit/TypeSymbolExtensionsTests.cs | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 test/Generators/Microsoft.Gen.Logging/Unit/TypeSymbolExtensionsTests.cs diff --git a/test/Generators/Microsoft.Gen.Logging/Unit/TypeSymbolExtensionsTests.cs b/test/Generators/Microsoft.Gen.Logging/Unit/TypeSymbolExtensionsTests.cs new file mode 100644 index 00000000000..094c19c2dd6 --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/Unit/TypeSymbolExtensionsTests.cs @@ -0,0 +1,312 @@ +// 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.CodeAnalysis; +using Microsoft.Gen.Logging.Parsing; +using Xunit; + +namespace Microsoft.Gen.Logging.Test; + +public class TypeSymbolExtensionsTests +{ + private readonly Action _diagCallback = (_, __, ___) => { }; + + [Theory] + [InlineData("TestEnumerableInt : List", "TestEnumerableInt", true)] + [InlineData("TestEnumerable : List", "TestEnumerable", true)] + [InlineData("NotUsed", "IEnumerable", true)] + [InlineData("TestClass", "NonEnumerable", false)] + [InlineData("TestClassDerived : NonEnumerable", "TestClassDerived", false)] + [InlineData("NotUsed", "bool", false)] + public void ValidateIsEnumerable(string classDefinition, string typeReference, bool expectedResult) + { + // Generate the code + string source = $@" + namespace Test + {{ + using System.Collections.Generic; + using Microsoft.Extensions.Logging; + + class {classDefinition} {{ }} + + class NonEnumerable {{ }} + + partial class C + {{ + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = ""M1"")] + static partial void M1(ILogger logger, {typeReference} property); + }} + }}"; + + // Create compilation and extract symbols + Compilation compilation = CompilationHelper.CreateCompilation(source); + SymbolHolder? symbolHolder = SymbolLoader.LoadSymbols(compilation, _diagCallback); + + IEnumerable methodSymbols = compilation.GetSymbolsWithName("M1", SymbolFilter.Member); + + // Assert + Assert.NotNull(symbolHolder); + ISymbol symbol = Assert.Single(methodSymbols); + var methodSymbol = Assert.IsAssignableFrom(symbol); + var parameterSymbol = Assert.Single(methodSymbol.Parameters, p => p.Name == "property"); + + Assert.Equal(expectedResult, parameterSymbol.Type.IsEnumerable(symbolHolder)); + } + + [Theory] + [InlineData("TestFormattable", "TestFormattable", true)] + [InlineData("TestFormattable : IFormattable", "TestFormattable", true)] + [InlineData("TestFormattable", "NonFormattable", false)] + public void ValidateImplementsIFormattable(string classDefinition, string typeReference, bool expectedResult) + { + // Generate the code + string source = $@" + namespace Test + {{ + using System; + using Microsoft.Extensions.Logging; + + class {classDefinition} + {{ + public string ToString(string? format, IFormatProvider? formatProvider) + {{ + throw new NotImplementedException(); + }} + }} + + class NonFormattable {{ }} + + partial class C + {{ + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = ""M1"")] + static partial void M1(ILogger logger, {typeReference} property); + }} + }}"; + + // Create compilation and extract symbols + Compilation compilation = CompilationHelper.CreateCompilation(source); + SymbolHolder? symbolHolder = SymbolLoader.LoadSymbols(compilation, _diagCallback); + IEnumerable methodSymbols = compilation.GetSymbolsWithName("M1", SymbolFilter.Member); + + // Assert + Assert.NotNull(symbolHolder); + ISymbol symbol = Assert.Single(methodSymbols); + var methodSymbol = Assert.IsAssignableFrom(symbol); + var parameterSymbol = Assert.Single(methodSymbol.Parameters, p => p.Name == "property"); + + Assert.Equal(expectedResult, parameterSymbol.Type.ImplementsIFormattable(symbolHolder)); + } + + [Theory] + [InlineData("TestConvertible", "TestConvertible", true)] + [InlineData("TestConvertible : IConvertible", "TestConvertible", true)] + [InlineData("TestConvertible", "NonConvertible", false)] + public void ValidateImplementsIConvertible(string classDefinition, string typeReference, bool expectedResult) + { + // Generate the code + string source = $@" + namespace Test + {{ + using System; + using Microsoft.Extensions.Logging; + + class {classDefinition} + {{ + public string ToString(IFormatProvider? formatProvider) + {{ + throw new NotImplementedException(); + }} + }} + + class NonConvertible {{ }} + + partial class C + {{ + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = ""M1"")] + static partial void M1(ILogger logger, {typeReference} property); + }} + }}"; + + // Create compilation and extract symbols + Compilation compilation = CompilationHelper.CreateCompilation(source); + SymbolHolder? symbolHolder = SymbolLoader.LoadSymbols(compilation, _diagCallback); + IEnumerable methodSymbols = compilation.GetSymbolsWithName("M1", SymbolFilter.Member); + + // Assert + Assert.NotNull(symbolHolder); + ISymbol symbol = Assert.Single(methodSymbols); + var methodSymbol = Assert.IsAssignableFrom(symbol); + var parameterSymbol = Assert.Single(methodSymbol.Parameters, p => p.Name == "property"); + + Assert.Equal(expectedResult, parameterSymbol.Type.ImplementsIConvertible(symbolHolder)); + } + + [Theory] + [InlineData("TestISpanFormattable : ISpanFormattable", "TestISpanFormattable", true)] + [InlineData("TestISpanFormattable", "NonConvertible", false)] + public void ValidateImplementsISpanFormattable(string classDefinition, string typeReference, bool expectedResult) + { + // Generate the code + string source = $@" + namespace Test + {{ + using System; + using Microsoft.Extensions.Logging; + + class {classDefinition} + {{ + public string ToString(string? format, IFormatProvider? formatProvider) + {{ + throw new NotImplementedException(); + }} + + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider provider) + {{ + throw new NotImplementedException(); + }} + }} + + class NonSpanFormattable {{ }} + + partial class C + {{ + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = ""M1"")] + static partial void M1(ILogger logger, {typeReference} property); + }} + }}"; + + // Create compilation and extract symbols + Compilation compilation = CompilationHelper.CreateCompilation(source); + SymbolHolder? symbolHolder = SymbolLoader.LoadSymbols(compilation, _diagCallback); + IEnumerable methodSymbols = compilation.GetSymbolsWithName("M1", SymbolFilter.Member); + + // Assert + Assert.NotNull(symbolHolder); + ISymbol symbol = Assert.Single(methodSymbols); + var methodSymbol = Assert.IsAssignableFrom(symbol); + var parameterSymbol = Assert.Single(methodSymbol.Parameters, p => p.Name == "property"); + + Assert.Equal(expectedResult, parameterSymbol.Type.ImplementsISpanFormattable(symbolHolder)); + } + + [Theory] + [InlineData("string", true)] + [InlineData("bool", true)] + [InlineData("int", true)] + [InlineData("NonSpecialType", false)] + [InlineData("TestClassDerived", false)] + [InlineData("TimeSpan", false)] + [InlineData("Uri", false)] + public void ValidateIsSpecialType(string typeReference, bool expectedResult) + { + // Generate the code + string source = $@" + namespace Test + {{ + using System.Collections.Generic; + using Microsoft.Extensions.Logging; + + class NonSpecialType {{ }} + + class TestClassDerived: NonSpecialType {{ }} + + partial class C + {{ + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = ""M1"")] + static partial void M1(ILogger logger, {typeReference} property); + }} + }}"; + + // Create compilation and extract symbols + Compilation compilation = CompilationHelper.CreateCompilation(source); + SymbolHolder? symbolHolder = SymbolLoader.LoadSymbols(compilation, _diagCallback); + + IEnumerable methodSymbols = compilation.GetSymbolsWithName("M1", SymbolFilter.Member); + + // Assert + Assert.NotNull(symbolHolder); + ISymbol symbol = Assert.Single(methodSymbols); + var methodSymbol = Assert.IsAssignableFrom(symbol); + var parameterSymbol = Assert.Single(methodSymbol.Parameters, p => p.Name == "property"); + + Assert.Equal(expectedResult, parameterSymbol.Type.IsSpecialType(symbolHolder)); + } + + [Theory] + [InlineData("ToString", true)] + [InlineData("RandomMethod", false)] + [InlineData("ToooooString", false)] + public void ValidateHasCustomToString(string methodName, bool expectedResult) + { + // Generate the code + string source = $@" + namespace Test + {{ + using System; + using Microsoft.Extensions.Logging; + + class Test + {{ + public override string {methodName}() + {{ + throw new NotImplementedException(); + }} + }} + + class NonConvertible {{ }} + + partial class C + {{ + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = ""M1"")] + static partial void M1(ILogger logger, Test property); + }} + }}"; + + // Create compilation and extract symbols + Compilation compilation = CompilationHelper.CreateCompilation(source); + SymbolHolder? symbolHolder = SymbolLoader.LoadSymbols(compilation, _diagCallback); + IEnumerable methodSymbols = compilation.GetSymbolsWithName("M1", SymbolFilter.Member); + + // Assert + Assert.NotNull(symbolHolder); + ISymbol symbol = Assert.Single(methodSymbols); + var methodSymbol = Assert.IsAssignableFrom(symbol); + var parameterSymbol = Assert.Single(methodSymbol.Parameters, p => p.Name == "property"); + + Assert.Equal(expectedResult, parameterSymbol.Type.HasCustomToString()); + } + + [Fact] + public void GetPossiblyNullWrappedType_NullableT_ReturnsT() + { + Compilation compilation = CompilationHelper.CreateCompilation("public class TestClass { }"); + INamedTypeSymbol nullableType = compilation.GetSpecialType(SpecialType.System_Nullable_T); + INamedTypeSymbol intType = compilation.GetSpecialType(SpecialType.System_Int32); + INamedTypeSymbol nullableIntType = nullableType.Construct(intType); + var result = nullableIntType.GetPossiblyNullWrappedType(); + Assert.Equal(intType, result); + } + + [Fact] + public void GetPossiblyNullWrappedType_ListT_ReturnsListT() + { + Compilation compilation = CompilationHelper.CreateCompilation("using System.Collections.Generic; public class TestClass { }"); + INamedTypeSymbol listType = compilation.GetTypeByMetadataName("System.Collections.Generic.List`1")!; + INamedTypeSymbol intType = compilation.GetSpecialType(SpecialType.System_Int32); + INamedTypeSymbol listIntType = listType.Construct(intType); + var result = listIntType.GetPossiblyNullWrappedType(); + Assert.Equal(listIntType, result); + } + + [Fact] + public void GetPossiblyNullWrappedType_T_ReturnsT() + { + Compilation compilation = CompilationHelper.CreateCompilation("public class TestClass { }"); + INamedTypeSymbol intType = compilation.GetSpecialType(SpecialType.System_Int32); + var result = intType.GetPossiblyNullWrappedType(); + Assert.Equal(intType, result); + } +} From 953f93f011251892eca1e26ac3ee3b7a93a48838 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 6 Dec 2024 03:23:09 -0800 Subject: [PATCH 174/190] Update OpenAI dependency to 2.1.0 (#5725) --- eng/packages/General.props | 3 ++- .../OpenAIChatClient.cs | 27 +++++++------------ .../OpenAIChatClientTests.cs | 12 +++++++++ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/eng/packages/General.props b/eng/packages/General.props index ff2c3010128..10542a2561a 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -11,12 +11,13 @@ - + + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index cf8daec75ef..da7f93c9da5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -356,29 +356,20 @@ private static UsageDetails ToUsageDetails(ChatTokenUsage tokenUsage) if (tokenUsage.InputTokenDetails is ChatInputTokenUsageDetails inputDetails) { - if (inputDetails.AudioTokenCount is int audioTokenCount) - { - destination.AdditionalCounts.Add( - $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", - audioTokenCount); - } + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", + inputDetails.AudioTokenCount); - if (inputDetails.CachedTokenCount is int cachedTokenCount) - { - destination.AdditionalCounts.Add( - $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", - cachedTokenCount); - } + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", + inputDetails.CachedTokenCount); } if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails outputDetails) { - if (outputDetails.AudioTokenCount is int audioTokenCount) - { - destination.AdditionalCounts.Add( - $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", - audioTokenCount); - } + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", + outputDetails.AudioTokenCount); destination.AdditionalCounts.Add( $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)}", diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 986ebefd518..927063e7706 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -201,7 +201,9 @@ public async Task BasicRequestResponse_NonStreaming() Assert.Equal(17, response.Usage.TotalTokenCount); Assert.Equal(new Dictionary { + { "InputTokenDetails.AudioTokenCount", 0 }, { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.ReasoningTokenCount", 90 } }, response.Usage.AdditionalCounts); @@ -492,7 +494,9 @@ public async Task MultiPartSystemMessage_NonStreaming() Assert.Equal(57, response.Usage.TotalTokenCount); Assert.Equal(new Dictionary { + { "InputTokenDetails.AudioTokenCount", 0 }, { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.ReasoningTokenCount", 90 } }, response.Usage.AdditionalCounts); @@ -589,7 +593,9 @@ public async Task EmptyAssistantMessage_NonStreaming() Assert.Equal(57, response.Usage.TotalTokenCount); Assert.Equal(new Dictionary { + { "InputTokenDetails.AudioTokenCount", 0 }, { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.ReasoningTokenCount", 90 } }, response.Usage.AdditionalCounts); @@ -699,7 +705,9 @@ public async Task FunctionCallContent_NonStreaming() Assert.Equal(new Dictionary { + { "InputTokenDetails.AudioTokenCount", 0 }, { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.ReasoningTokenCount", 90 } }, response.Usage.AdditionalCounts); @@ -817,7 +825,9 @@ public async Task FunctionCallContent_Streaming() Assert.Equal(new Dictionary { + { "InputTokenDetails.AudioTokenCount", 0 }, { "InputTokenDetails.CachedTokenCount", 0 }, + { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.ReasoningTokenCount", 90 } }, usage.Details.AdditionalCounts); } @@ -954,7 +964,9 @@ public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() Assert.Equal(57, response.Usage.TotalTokenCount); Assert.Equal(new Dictionary { + { "InputTokenDetails.AudioTokenCount", 0 }, { "InputTokenDetails.CachedTokenCount", 20 }, + { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.ReasoningTokenCount", 90 } }, response.Usage.AdditionalCounts); From 0bf057b589d0af0b2be44b96c1e2412e425af337 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Mon, 9 Dec 2024 13:50:13 +1100 Subject: [PATCH 175/190] Fix build (#5728) --- .../FakeTimeProviderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs index 5c89abae5b8..cf138a91fb3 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/FakeTimeProviderTests.cs @@ -294,6 +294,7 @@ public async Task Advance_CancelledToken_ThrowsTaskCanceledException() await Assert.ThrowsAsync(() => timeProvider.Delay(TimeSpan.FromTicks(1), cts.Token)); } +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks [Fact] public async Task WaitAsync_NegativeTimeout_Throws() { @@ -308,7 +309,6 @@ public async Task WaitAsync_NegativeTimeout_Throws() await Assert.ThrowsAsync(() => source.Task.WaitAsync(TimeSpan.FromMilliseconds(-2), timeProvider, CancellationToken.None)); } -#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks [Fact] public async Task WaitAsync_ValidTimeout_CompletesSuccessfully() { From eb383d49b0f39b9ddce23c82fc42b67f37c3b6ce Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 9 Dec 2024 04:23:31 -0500 Subject: [PATCH 176/190] Add a few missing options to OpenAIChatclient.ToOpenAIOptions (#5727) --- .../Microsoft.Extensions.AI.OpenAI.csproj | 2 +- .../OpenAIChatClient.cs | 13 +++++ .../OpenAIChatClientTests.cs | 54 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) 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 43991fa84e6..d3d09766d69 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 @@ -9,7 +9,7 @@ preview true - 66 + 72 0 diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index da7f93c9da5..7df4747fc6f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -455,6 +455,19 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { result.TopLogProbabilityCount = topLogProbabilityCountInt; } + + if (additionalProperties.TryGetValue(nameof(result.Metadata), out IDictionary? metadata)) + { + foreach (KeyValuePair kvp in metadata) + { + result.Metadata[kvp.Key] = kvp.Value; + } + } + + if (additionalProperties.TryGetValue(nameof(result.StoredOutputEnabled), out bool storeOutputEnabled)) + { + result.StoredOutputEnabled = storeOutputEnabled; + } } if (options.Tools is { Count: > 0 } tools) diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 927063e7706..ac1b397364d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -291,6 +291,60 @@ public async Task BasicRequestResponse_Streaming() }, usage.Details.AdditionalCounts); } + [Fact] + public async Task NonStronglyTypedOptions_AllSent() + { + const string Input = """ + {"messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o-mini", + "store":true, + "metadata":{"something":"else"}, + "logit_bias":{"12":34}, + "logprobs":true, + "top_logprobs":42, + "parallel_tool_calls":false, + "user":"12345"} + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?" + }, + "finish_reason": "stop" + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + Assert.NotNull(await client.CompleteAsync("hello", new() + { + AdditionalProperties = new() + { + ["StoredOutputEnabled"] = true, + ["Metadata"] = new Dictionary + { + ["something"] = "else", + }, + ["LogitBiases"] = new Dictionary { { 12, 34 } }, + ["IncludeLogProbabilities"] = true, + ["TopLogProbabilityCount"] = 42, + ["AllowParallelToolCalls"] = false, + ["EndUserId"] = "12345", + }, + })); + } + [Fact] public async Task MultipleMessages_NonStreaming() { From 40fa575b5d22fe57aecc3ed41100516eadc67ad4 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 10 Dec 2024 09:08:18 +0000 Subject: [PATCH 177/190] Ollama support for streaming function calling and native structured output (#5730) --- .../ChatCompletion/ChatResponseFormat.cs | 7 +-- .../ChatCompletion/ChatResponseFormatJson.cs | 21 ++----- ...icrosoft.Extensions.AI.Abstractions.csproj | 2 +- .../OllamaChatClient.cs | 54 +++++++++++++---- .../OllamaChatRequest.cs | 3 +- .../OpenAIChatClient.cs | 8 ++- .../ChatClientStructuredOutputExtensions.cs | 6 +- .../ChatCompletion/ChatOptionsTests.cs | 3 +- .../ChatCompletion/ChatResponseFormatTests.cs | 60 +++++-------------- .../AzureAIInferenceChatClientTests.cs | 4 +- .../ChatClientIntegrationTests.cs | 27 +++++++++ .../OllamaChatClientIntegrationTests.cs | 6 -- ...atClientStructuredOutputExtensionsTests.cs | 6 +- 13 files changed, 110 insertions(+), 97 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs index 006acfe835c..ac59cfc263e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs @@ -1,9 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Text.Json.Serialization; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -33,8 +32,8 @@ private protected ChatResponseFormat() /// An optional description of the schema. /// The instance. public static ChatResponseFormatJson ForJsonSchema( - [StringSyntax(StringSyntaxAttribute.Json)] string schema, string? schemaName = null, string? schemaDescription = null) => - new(Throw.IfNull(schema), + JsonElement schema, string? schemaName = null, string? schemaDescription = null) => + new(schema, schemaName, schemaDescription); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs index 23b6ff635a8..673c2c51474 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormatJson.cs @@ -1,9 +1,8 @@ // 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.Text.Json; using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; @@ -19,7 +18,7 @@ public sealed class ChatResponseFormatJson : ChatResponseFormat /// A description of the schema. [JsonConstructor] public ChatResponseFormatJson( - [StringSyntax(StringSyntaxAttribute.Json)] string? schema, string? schemaName = null, string? schemaDescription = null) + JsonElement? schema, string? schemaName = null, string? schemaDescription = null) { if (schema is null && (schemaName is not null || schemaDescription is not null)) { @@ -34,7 +33,7 @@ public ChatResponseFormatJson( } /// Gets the JSON schema associated with the response, or null if there is none. - public string? Schema { get; } + public JsonElement? Schema { get; } /// Gets a name for the schema. public string? SchemaName { get; } @@ -42,19 +41,7 @@ public ChatResponseFormatJson( /// Gets a description of the schema. public string? SchemaDescription { get; } - /// - public override bool Equals(object? obj) => - obj is ChatResponseFormatJson other && - Schema == other.Schema && - SchemaName == other.SchemaName && - SchemaDescription == other.SchemaDescription; - - /// - public override int GetHashCode() => - Schema?.GetHashCode(StringComparison.Ordinal) ?? - typeof(ChatResponseFormatJson).GetHashCode(); - /// Gets a string representing this instance to display in the debugger. [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private string DebuggerDisplay => Schema ?? "JSON"; + private string DebuggerDisplay => Schema?.ToString() ?? "JSON"; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index 756ec27adc4..4d7e314a0e4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -9,7 +9,7 @@ preview true - 84 + 83 0 diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs index 4f923434e3a..408fa36d876 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs @@ -23,6 +23,7 @@ namespace Microsoft.Extensions.AI; public sealed class OllamaChatClient : IChatClient { private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; + private static readonly JsonElement _schemalessJsonResponseFormatValue = JsonDocument.Parse("\"json\"").RootElement; /// The api/chat endpoint URI. private readonly Uri _apiChatEndpoint; @@ -111,15 +112,6 @@ public async IAsyncEnumerable CompleteStreamingAs { _ = Throw.IfNull(chatMessages); - if (options?.Tools is { Count: > 0 }) - { - // We can actually make it work by using the /generate endpoint like the eShopSupport sample does, - // but it's complicated. Really it should be Ollama's job to support this. - throw new NotSupportedException( - "Currently, Ollama does not support function calls in streaming mode. " + - "See Ollama docs at https://github.com/ollama/ollama/blob/main/docs/api.md#parameters-1 to see whether support has since been added."); - } - using HttpRequestMessage request = new(HttpMethod.Post, _apiChatEndpoint) { Content = JsonContent.Create(ToOllamaChatRequest(chatMessages, options, stream: true), JsonContext.Default.OllamaChatRequest) @@ -158,7 +150,22 @@ public async IAsyncEnumerable CompleteStreamingAs if (chunk.Message is { } message) { - update.Contents.Add(new TextContent(message.Content)); + 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) @@ -231,8 +238,7 @@ private static ChatMessage FromOllamaMessage(OllamaChatResponseMessage message) { if (toolCall.Function is { } function) { - var id = Guid.NewGuid().ToString().Substring(0, 8); - contents.Add(new FunctionCallContent(id, function.Name, function.Arguments)); + contents.Add(ToFunctionCallContent(function)); } } } @@ -247,11 +253,33 @@ private static ChatMessage FromOllamaMessage(OllamaChatResponseMessage message) return new ChatMessage(new(message.Role), contents); } + 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 jsonFormat.Schema ?? _schemalessJsonResponseFormatValue; + } + else + { + return null; + } + } + private OllamaChatRequest ToOllamaChatRequest(IList chatMessages, ChatOptions? options, bool stream) { OllamaChatRequest request = new() { - Format = options?.ResponseFormat is ChatResponseFormatJson ? "json" : null, + Format = ToOllamaChatResponseFormat(options?.ResponseFormat), Messages = chatMessages.SelectMany(ToOllamaChatRequestMessages).ToArray(), Model = options?.ModelId ?? Metadata.ModelId ?? string.Empty, Stream = stream, diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs index 5d2f63ddfe5..a5b23d567a4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Text.Json; namespace Microsoft.Extensions.AI; @@ -9,7 +10,7 @@ internal sealed class OllamaChatRequest { public required string Model { get; set; } public required OllamaChatRequestMessage[] Messages { get; set; } - public string? Format { 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.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 7df4747fc6f..09ad9aa18ac 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -500,8 +500,12 @@ private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) } else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) { - result.ResponseFormat = jsonFormat.Schema is string jsonSchema ? - OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat(jsonFormat.SchemaName ?? "json_schema", BinaryData.FromString(jsonSchema), jsonFormat.SchemaDescription) : + result.ResponseFormat = jsonFormat.Schema is { } jsonSchema ? + OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName ?? "json_schema", + BinaryData.FromBytes( + JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription) : OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index 0f847dbb296..0e5adb6d811 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -128,12 +128,12 @@ public static async Task> CompleteAsync( inferenceOptions: _inferenceOptions); bool isWrappedInObject; - string schema; + JsonElement schema; if (SchemaRepresentsObject(schemaElement)) { // For object-representing schemas, we can use them as-is isWrappedInObject = false; - schema = JsonSerializer.Serialize(schemaElement, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement))); + schema = schemaElement; } else { @@ -141,7 +141,7 @@ public static async Task> CompleteAsync( // the real LLM providers today require an object schema as the root. This is currently // true even for providers that support native structured output. isWrappedInObject = true; - schema = JsonSerializer.Serialize(new JsonObject + schema = JsonSerializer.SerializeToElement(new JsonObject { { "$schema", "https://json-schema.org/draft/2020-12/schema" }, { "type", "object" }, diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs index fcd40a2f446..349623d7b08 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs @@ -155,8 +155,7 @@ public void JsonSerialization_Roundtrips() Assert.Equal(0.4f, deserialized.FrequencyPenalty); Assert.Equal(0.5f, deserialized.PresencePenalty); Assert.Equal(12345, deserialized.Seed); - Assert.Equal(ChatResponseFormat.Json, deserialized.ResponseFormat); - Assert.NotSame(ChatResponseFormat.Json, deserialized.ResponseFormat); + Assert.IsType(deserialized.ResponseFormat); Assert.Equal("modelId", deserialized.ModelId); Assert.NotSame(stopSequences, deserialized.StopSequences); Assert.Equal(stopSequences, deserialized.StopSequences); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs index 22c7a99bdaf..7d1fb1fede8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs @@ -9,6 +9,8 @@ namespace Microsoft.Extensions.AI; public class ChatResponseFormatTests { + private static JsonElement EmptySchema => JsonDocument.Parse("{}").RootElement; + [Fact] public void Singletons_Idempotent() { @@ -36,47 +38,12 @@ public void Constructor_PropsDefaulted() [Fact] public void Constructor_PropsRoundtrip() { - ChatResponseFormatJson f = new("{}", "name", "description"); - Assert.Equal("{}", f.Schema); + ChatResponseFormatJson f = new(EmptySchema, "name", "description"); + Assert.Equal("{}", JsonSerializer.Serialize(f.Schema, TestJsonSerializerContext.Default.JsonElement)); Assert.Equal("name", f.SchemaName); Assert.Equal("description", f.SchemaDescription); } - [Fact] - public void Equality_ComparersProduceExpectedResults() - { - Assert.True(ChatResponseFormat.Text == ChatResponseFormat.Text); - Assert.True(ChatResponseFormat.Text.Equals(ChatResponseFormat.Text)); - Assert.Equal(ChatResponseFormat.Text.GetHashCode(), ChatResponseFormat.Text.GetHashCode()); - Assert.False(ChatResponseFormat.Text.Equals(ChatResponseFormat.Json)); - Assert.False(ChatResponseFormat.Text.Equals(new ChatResponseFormatJson(null))); - Assert.False(ChatResponseFormat.Text.Equals(new ChatResponseFormatJson("{}"))); - - Assert.True(ChatResponseFormat.Json == ChatResponseFormat.Json); - Assert.True(ChatResponseFormat.Json.Equals(ChatResponseFormat.Json)); - Assert.False(ChatResponseFormat.Json.Equals(ChatResponseFormat.Text)); - Assert.False(ChatResponseFormat.Json.Equals(new ChatResponseFormatJson("{}"))); - - Assert.True(ChatResponseFormat.Json.Equals(new ChatResponseFormatJson(null))); - Assert.Equal(ChatResponseFormat.Json.GetHashCode(), new ChatResponseFormatJson(null).GetHashCode()); - - Assert.True(new ChatResponseFormatJson("{}").Equals(new ChatResponseFormatJson("{}"))); - Assert.Equal(new ChatResponseFormatJson("{}").GetHashCode(), new ChatResponseFormatJson("{}").GetHashCode()); - - Assert.False(new ChatResponseFormatJson("""{ "prop": 42 }""").Equals(new ChatResponseFormatJson("""{ "prop": 43 }"""))); - Assert.NotEqual(new ChatResponseFormatJson("""{ "prop": 42 }""").GetHashCode(), new ChatResponseFormatJson("""{ "prop": 43 }""").GetHashCode()); // technically not guaranteed - - Assert.False(new ChatResponseFormatJson("""{ "prop": 42 }""").Equals(new ChatResponseFormatJson("""{ "PROP": 42 }"""))); - Assert.NotEqual(new ChatResponseFormatJson("""{ "prop": 42 }""").GetHashCode(), new ChatResponseFormatJson("""{ "PROP": 42 }""").GetHashCode()); // technically not guaranteed - - Assert.True(new ChatResponseFormatJson("{}", "name", "description").Equals(new ChatResponseFormatJson("{}", "name", "description"))); - Assert.False(new ChatResponseFormatJson("{}", "name", "description").Equals(new ChatResponseFormatJson("{}", "name", "description2"))); - Assert.False(new ChatResponseFormatJson("{}", "name", "description").Equals(new ChatResponseFormatJson("{}", "name2", "description"))); - Assert.False(new ChatResponseFormatJson("{}", "name", "description").Equals(new ChatResponseFormatJson("{}", "name2", "description2"))); - - Assert.Equal(new ChatResponseFormatJson("{}", "name", "description").GetHashCode(), new ChatResponseFormatJson("{}", "name", "description").GetHashCode()); - } - [Fact] public void Serialization_TextRoundtrips() { @@ -94,19 +61,24 @@ public void Serialization_JsonRoundtrips() Assert.Equal("""{"$type":"json"}""", json); ChatResponseFormat? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatResponseFormat); - Assert.Equal(ChatResponseFormat.Json, result); + var actual = Assert.IsType(result); + Assert.Null(actual.Schema); + Assert.Null(actual.SchemaDescription); + Assert.Null(actual.SchemaName); } [Fact] public void Serialization_ForJsonSchemaRoundtrips() { - string json = JsonSerializer.Serialize(ChatResponseFormat.ForJsonSchema("[1,2,3]", "name", "description"), TestJsonSerializerContext.Default.ChatResponseFormat); - Assert.Equal("""{"$type":"json","schema":"[1,2,3]","schemaName":"name","schemaDescription":"description"}""", json); + string json = JsonSerializer.Serialize( + ChatResponseFormat.ForJsonSchema(JsonSerializer.Deserialize("[1,2,3]"), "name", "description"), + TestJsonSerializerContext.Default.ChatResponseFormat); + Assert.Equal("""{"$type":"json","schema":[1,2,3],"schemaName":"name","schemaDescription":"description"}""", json); ChatResponseFormat? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ChatResponseFormat); - Assert.Equal(ChatResponseFormat.ForJsonSchema("[1,2,3]", "name", "description"), result); - Assert.Equal("[1,2,3]", (result as ChatResponseFormatJson)?.Schema); - Assert.Equal("name", (result as ChatResponseFormatJson)?.SchemaName); - Assert.Equal("description", (result as ChatResponseFormatJson)?.SchemaDescription); + var actual = Assert.IsType(result); + Assert.Equal("[1,2,3]", JsonSerializer.Serialize(actual.Schema, TestJsonSerializerContext.Default.JsonElement)); + Assert.Equal("name", actual.SchemaName); + Assert.Equal("description", actual.SchemaDescription); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 3cd2fd16e33..3797f4b6c47 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -406,7 +406,7 @@ public async Task ResponseFormat_JsonSchema_NonStreaming() Assert.NotNull(await client.CompleteAsync("hello", new() { - ResponseFormat = ChatResponseFormat.ForJsonSchema(""" + ResponseFormat = ChatResponseFormat.ForJsonSchema(JsonSerializer.Deserialize(""" { "type": "object", "properties": { @@ -416,7 +416,7 @@ public async Task ResponseFormat_JsonSchema_NonStreaming() }, "required": ["description"] } - """, "DescribedObject", "An object with a description"), + """), "DescribedObject", "An object with a description"), })); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 8bff6a01bd3..3f3bf7cd1dd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -758,6 +758,33 @@ public virtual async Task CompleteAsync_StructuredOutput_WithFunctions() Assert.Equal(expectedPerson.Job, response.Result.Job); } + [ConditionalFact] + public virtual async Task CompleteAsync_StructuredOutput_Native() + { + SkipIfNotEnabled(); + + var capturedCalls = new List>(); + var captureOutputChatClient = _chatClient.AsBuilder() + .Use((messages, options, nextAsync, cancellationToken) => + { + capturedCalls.Add([.. messages]); + return nextAsync(messages, options, cancellationToken); + }) + .Build(); + + var response = await captureOutputChatClient.CompleteAsync(""" + Supply a JSON object to represent Jimbo Smith from Cardiff. + """, useNativeJsonSchema: true); + + Assert.Equal("Jimbo Smith", response.Result.FullName); + Assert.Contains("Cardiff", response.Result.HomeTown); + + // Verify it used *native* structured output, i.e., no prompt augmentation + Assert.All( + Assert.Single(capturedCalls), + message => Assert.DoesNotContain("schema", message.Text)); + } + private class Person { #pragma warning disable S1144, S3459 // Unassigned members should be removed diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs index 76a3f940595..9318c095200 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs @@ -18,12 +18,6 @@ public class OllamaChatClientIntegrationTests : ChatClientIntegrationTests new OllamaChatClient(endpoint, "llama3.1") : null; - public override Task FunctionInvocation_AutomaticallyInvokeFunction_WithParameters_Streaming() => - throw new SkipTestException("Ollama does not currently support function invocation with streaming."); - - public override Task Logging_LogsFunctionCalls_Streaming() => - throw new SkipTestException("Ollama does not currently support function invocation with streaming."); - public override Task FunctionInvocation_RequireAny() => throw new SkipTestException("Ollama does not currently support requiring function invocation."); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index acb6142935e..5b7bcdddf73 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -177,10 +177,12 @@ public async Task CanUseNativeStructuredOutput() var responseFormat = Assert.IsType(options!.ResponseFormat); Assert.Equal(nameof(Animal), responseFormat.SchemaName); Assert.Equal("Some test description", responseFormat.SchemaDescription); - Assert.Contains("https://json-schema.org/draft/2020-12/schema", responseFormat.Schema); + + var responseFormatJsonSchema = JsonSerializer.Serialize(responseFormat.Schema, TestJsonSerializerContext.Default.JsonElement); + Assert.Contains("https://json-schema.org/draft/2020-12/schema", responseFormatJsonSchema); foreach (Species v in Enum.GetValues(typeof(Species))) { - Assert.Contains(v.ToString(), responseFormat.Schema); // All enum values are described as strings + Assert.Contains(v.ToString(), responseFormatJsonSchema); // All enum values are described as strings } // The chat history isn't mutated any further, since native structured output is used instead of a prompt From d5fca58aa0dffcd1c2753f7948464316b6a29871 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 10 Dec 2024 13:34:49 +0000 Subject: [PATCH 178/190] Improve reliability of CompleteAsync_StructuredOutputEnum test. Fixes #5570 (#5731) --- .../ChatClientIntegrationTests.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 3f3bf7cd1dd..bff072e1bd4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -8,7 +8,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -720,11 +719,11 @@ public virtual async Task CompleteAsync_StructuredOutputEnum() { SkipIfNotEnabled(); - var response = await _chatClient.CompleteAsync(""" - I'm using a Macbook Pro with an M2 chip. What architecture am I using? + var response = await _chatClient.CompleteAsync(""" + Taylor Swift is a famous singer and songwriter. What is her job? """); - Assert.Equal(Architecture.Arm64, response.Result); + Assert.Equal(JobType.PopStar, response.Result); } [ConditionalFact] From 8bc5f927042ba7ee3e66735a46b4df2d79afc99c Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 10 Dec 2024 21:55:53 +0200 Subject: [PATCH 179/190] Add OpenAI serialization helper methods. (#5697) * Add a System.Net.ServerSentEvents polyfill. * Move model mapping helpers to standalone class. * Skeleton implementation of OpenAI serialization methods. * Add unit testing. * Also normalize escaped line endings. * Fix unix test failures * Exclude System.Net.Sse from code coverage * Improve test coverage. * Remove msbuild artifact * Fix merge conflicts. * Address feedback. * Address feedback. * Address feedback. * Add function result name inference. --- eng/MSBuild/Shared.props | 4 + .../JsonModelHelpers.cs | 31 + .../Microsoft.Extensions.AI.OpenAI.csproj | 4 + .../OpenAIChatClient.cs | 599 +------------- .../OpenAIChatCompletionRequest.cs | 32 + .../OpenAIJsonContext.cs | 4 +- .../OpenAIModelMapper.ChatCompletion.cs | 610 +++++++++++++++ .../OpenAIModelMapper.ChatMessage.cs | 255 ++++++ ...nAIModelMappers.StreamingChatCompletion.cs | 205 +++++ .../OpenAISerializationHelpers.cs | 98 +++ src/Shared/ServerSentEvents/ArrayBuffer.cs | 197 +++++ src/Shared/ServerSentEvents/Helpers.cs | 127 +++ .../PooledByteBufferWriter.cs | 36 + src/Shared/ServerSentEvents/README.md | 11 + src/Shared/ServerSentEvents/SseFormatter.cs | 169 ++++ src/Shared/ServerSentEvents/SseItem.cs | 81 ++ src/Shared/ServerSentEvents/SseItemParser.cs | 12 + src/Shared/ServerSentEvents/SseParser.cs | 53 ++ src/Shared/ServerSentEvents/SseParser_1.cs | 569 ++++++++++++++ src/Shared/ServerSentEvents/ThrowHelper.cs | 34 + .../OpenAISerializationTests.cs | 740 ++++++++++++++++++ 21 files changed, 3283 insertions(+), 588 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/JsonModelHelpers.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatCompletionRequest.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMappers.StreamingChatCompletion.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISerializationHelpers.cs create mode 100644 src/Shared/ServerSentEvents/ArrayBuffer.cs create mode 100644 src/Shared/ServerSentEvents/Helpers.cs create mode 100644 src/Shared/ServerSentEvents/PooledByteBufferWriter.cs create mode 100644 src/Shared/ServerSentEvents/README.md create mode 100644 src/Shared/ServerSentEvents/SseFormatter.cs create mode 100644 src/Shared/ServerSentEvents/SseItem.cs create mode 100644 src/Shared/ServerSentEvents/SseItemParser.cs create mode 100644 src/Shared/ServerSentEvents/SseParser.cs create mode 100644 src/Shared/ServerSentEvents/SseParser_1.cs create mode 100644 src/Shared/ServerSentEvents/ThrowHelper.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs diff --git a/eng/MSBuild/Shared.props b/eng/MSBuild/Shared.props index a68b0e4298f..dee583f7e39 100644 --- a/eng/MSBuild/Shared.props +++ b/eng/MSBuild/Shared.props @@ -14,6 +14,10 @@ + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/JsonModelHelpers.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/JsonModelHelpers.cs new file mode 100644 index 00000000000..5f6b92d2f01 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/JsonModelHelpers.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel.Primitives; + +namespace Microsoft.Extensions.AI; + +/// +/// Defines a set of helper methods for working with types. +/// +internal static class JsonModelHelpers +{ + public static BinaryData Serialize(TModel value) + where TModel : IJsonModel + { + return value.Write(ModelReaderWriterOptions.Json); + } + + public static TModel Deserialize(BinaryData data) + where TModel : IJsonModel, new() + { + return JsonModelDeserializationWitness.Value.Create(data, ModelReaderWriterOptions.Json); + } + + private sealed class JsonModelDeserializationWitness + where TModel : IJsonModel, new() + { + public static readonly IJsonModel Value = new TModel(); + } +} 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 d3d09766d69..d3e969337e6 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 @@ -18,11 +18,15 @@ $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002;OPENAI002 true true + true true true + true + true + true diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 09ad9aa18ac..d0ec35d1e22 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -4,11 +4,7 @@ using System; using System.Collections.Generic; using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -16,7 +12,6 @@ using OpenAI.Chat; #pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S1135 // Track uses of "TODO" tags #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1204 // Static elements should appear before instance elements #pragma warning disable SA1108 // Block statements should not contain embedded comments @@ -26,8 +21,6 @@ namespace Microsoft.Extensions.AI; /// Represents an for an OpenAI or . public sealed class OpenAIChatClient : IChatClient { - private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; - /// Default OpenAI endpoint. private static readonly Uri _defaultOpenAIEndpoint = new("https://api.openai.com/v1"); @@ -110,224 +103,28 @@ public async Task CompleteAsync( { _ = Throw.IfNull(chatMessages); - // Make the call to OpenAI. - OpenAI.Chat.ChatCompletion response = (await _chatClient.CompleteChatAsync( - ToOpenAIChatMessages(chatMessages), - ToOpenAIOptions(options), - cancellationToken).ConfigureAwait(false)).Value; - - // Create the return message. - ChatMessage returnMessage = new() - { - RawRepresentation = response, - Role = ToChatRole(response.Role), - }; - - // Populate its content from those in the OpenAI response content. - foreach (ChatMessageContentPart contentPart in response.Content) - { - if (ToAIContent(contentPart) is AIContent aiContent) - { - returnMessage.Contents.Add(aiContent); - } - } - - // Also manufacture function calling content items from any tool calls in the response. - if (options?.Tools is { Count: > 0 }) - { - foreach (ChatToolCall toolCall in response.ToolCalls) - { - if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) - { - var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); - callContent.RawRepresentation = toolCall; - - returnMessage.Contents.Add(callContent); - } - } - } - - // Wrap the content in a ChatCompletion to return. - var completion = new ChatCompletion([returnMessage]) - { - RawRepresentation = response, - CompletionId = response.Id, - CreatedAt = response.CreatedAt, - ModelId = response.Model, - FinishReason = ToFinishReason(response.FinishReason), - }; + var openAIChatMessages = OpenAIModelMappers.ToOpenAIChatMessages(chatMessages, ToolCallJsonSerializerOptions); + var openAIOptions = OpenAIModelMappers.ToOpenAIOptions(options); - if (response.Usage is ChatTokenUsage tokenUsage) - { - completion.Usage = ToUsageDetails(tokenUsage); - } - - if (response.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) - { - (completion.AdditionalProperties ??= [])[nameof(response.ContentTokenLogProbabilities)] = contentTokenLogProbs; - } - - if (response.Refusal is string refusal) - { - (completion.AdditionalProperties ??= [])[nameof(response.Refusal)] = refusal; - } - - if (response.RefusalTokenLogProbabilities is { Count: > 0 } refusalTokenLogProbs) - { - (completion.AdditionalProperties ??= [])[nameof(response.RefusalTokenLogProbabilities)] = refusalTokenLogProbs; - } - - if (response.SystemFingerprint is string systemFingerprint) - { - (completion.AdditionalProperties ??= [])[nameof(response.SystemFingerprint)] = systemFingerprint; - } + // Make the call to OpenAI. + var response = await _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken).ConfigureAwait(false); - return completion; + return OpenAIModelMappers.FromOpenAIChatCompletion(response.Value, options); } /// - public async IAsyncEnumerable CompleteStreamingAsync( - IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public IAsyncEnumerable CompleteStreamingAsync( + IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(chatMessages); - Dictionary? functionCallInfos = null; - ChatRole? streamedRole = null; - ChatFinishReason? finishReason = null; - StringBuilder? refusal = null; - string? completionId = null; - DateTimeOffset? createdAt = null; - string? modelId = null; - string? fingerprint = null; - - // Process each update as it arrives - await foreach (OpenAI.Chat.StreamingChatCompletionUpdate chatCompletionUpdate in _chatClient.CompleteChatStreamingAsync( - ToOpenAIChatMessages(chatMessages), ToOpenAIOptions(options), cancellationToken).ConfigureAwait(false)) - { - // The role and finish reason may arrive during any update, but once they've arrived, the same value should be the same for all subsequent updates. - streamedRole ??= chatCompletionUpdate.Role is ChatMessageRole role ? ToChatRole(role) : null; - finishReason ??= chatCompletionUpdate.FinishReason is OpenAI.Chat.ChatFinishReason reason ? ToFinishReason(reason) : null; - completionId ??= chatCompletionUpdate.CompletionId; - createdAt ??= chatCompletionUpdate.CreatedAt; - modelId ??= chatCompletionUpdate.Model; - fingerprint ??= chatCompletionUpdate.SystemFingerprint; - - // Create the response content object. - StreamingChatCompletionUpdate completionUpdate = new() - { - CompletionId = chatCompletionUpdate.CompletionId, - CreatedAt = chatCompletionUpdate.CreatedAt, - FinishReason = finishReason, - ModelId = modelId, - RawRepresentation = chatCompletionUpdate, - Role = streamedRole, - }; - - // Populate it with any additional metadata from the OpenAI object. - if (chatCompletionUpdate.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) - { - (completionUpdate.AdditionalProperties ??= [])[nameof(chatCompletionUpdate.ContentTokenLogProbabilities)] = contentTokenLogProbs; - } - - if (chatCompletionUpdate.RefusalTokenLogProbabilities is { Count: > 0 } refusalTokenLogProbs) - { - (completionUpdate.AdditionalProperties ??= [])[nameof(chatCompletionUpdate.RefusalTokenLogProbabilities)] = refusalTokenLogProbs; - } - - if (fingerprint is not null) - { - (completionUpdate.AdditionalProperties ??= [])[nameof(chatCompletionUpdate.SystemFingerprint)] = fingerprint; - } - - // Transfer over content update items. - if (chatCompletionUpdate.ContentUpdate is { Count: > 0 }) - { - foreach (ChatMessageContentPart contentPart in chatCompletionUpdate.ContentUpdate) - { - if (ToAIContent(contentPart) is AIContent aiContent) - { - completionUpdate.Contents.Add(aiContent); - } - } - } - - // Transfer over refusal updates. - if (chatCompletionUpdate.RefusalUpdate is not null) - { - _ = (refusal ??= new()).Append(chatCompletionUpdate.RefusalUpdate); - } - - // Transfer over tool call updates. - if (chatCompletionUpdate.ToolCallUpdates is { Count: > 0 } toolCallUpdates) - { - foreach (StreamingChatToolCallUpdate toolCallUpdate in toolCallUpdates) - { - functionCallInfos ??= []; - if (!functionCallInfos.TryGetValue(toolCallUpdate.Index, out FunctionCallInfo? existing)) - { - functionCallInfos[toolCallUpdate.Index] = existing = new(); - } - - existing.CallId ??= toolCallUpdate.ToolCallId; - existing.Name ??= toolCallUpdate.FunctionName; - if (toolCallUpdate.FunctionArgumentsUpdate is { } update && !update.ToMemory().IsEmpty) - { - _ = (existing.Arguments ??= new()).Append(update.ToString()); - } - } - } - - // Transfer over usage updates. - if (chatCompletionUpdate.Usage is ChatTokenUsage tokenUsage) - { - var usageDetails = ToUsageDetails(tokenUsage); - completionUpdate.Contents.Add(new UsageContent(usageDetails)); - } - - // Now yield the item. - yield return completionUpdate; - } - - // Now that we've received all updates, combine any for function calls into a single item to yield. - if (functionCallInfos is not null) - { - StreamingChatCompletionUpdate completionUpdate = new() - { - CompletionId = completionId, - CreatedAt = createdAt, - FinishReason = finishReason, - ModelId = modelId, - Role = streamedRole, - }; + var openAIChatMessages = OpenAIModelMappers.ToOpenAIChatMessages(chatMessages, ToolCallJsonSerializerOptions); + var openAIOptions = OpenAIModelMappers.ToOpenAIOptions(options); - foreach (var entry in functionCallInfos) - { - FunctionCallInfo fci = entry.Value; - if (!string.IsNullOrWhiteSpace(fci.Name)) - { - var callContent = ParseCallContentFromJsonString( - fci.Arguments?.ToString() ?? string.Empty, - fci.CallId!, - fci.Name!); - completionUpdate.Contents.Add(callContent); - } - } - - // Refusals are about the model not following the schema for tool calls. As such, if we have any refusal, - // add it to this function calling item. - if (refusal is not null) - { - (completionUpdate.AdditionalProperties ??= [])[nameof(ChatMessageContentPart.Refusal)] = refusal.ToString(); - } - - // Propagate additional relevant metadata. - if (fingerprint is not null) - { - (completionUpdate.AdditionalProperties ??= [])[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)] = fingerprint; - } + // Make the call to OpenAI. + var chatCompletionUpdates = _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); - yield return completionUpdate; - } + return OpenAIModelMappers.FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, cancellationToken); } /// @@ -335,376 +132,4 @@ void IDisposable.Dispose() { // Nothing to dispose. Implementation required for the IChatClient interface. } - - /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. - private sealed class FunctionCallInfo - { - public string? CallId; - public string? Name; - public StringBuilder? Arguments; - } - - private static UsageDetails ToUsageDetails(ChatTokenUsage tokenUsage) - { - var destination = new UsageDetails - { - InputTokenCount = tokenUsage.InputTokenCount, - OutputTokenCount = tokenUsage.OutputTokenCount, - TotalTokenCount = tokenUsage.TotalTokenCount, - AdditionalCounts = new(), - }; - - if (tokenUsage.InputTokenDetails is ChatInputTokenUsageDetails inputDetails) - { - destination.AdditionalCounts.Add( - $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", - inputDetails.AudioTokenCount); - - destination.AdditionalCounts.Add( - $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", - inputDetails.CachedTokenCount); - } - - if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails outputDetails) - { - destination.AdditionalCounts.Add( - $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", - outputDetails.AudioTokenCount); - - destination.AdditionalCounts.Add( - $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)}", - outputDetails.ReasoningTokenCount); - } - - return destination; - } - - /// Converts an OpenAI role to an Extensions role. - private static ChatRole ToChatRole(ChatMessageRole role) => - role switch - { - ChatMessageRole.System => ChatRole.System, - ChatMessageRole.User => ChatRole.User, - ChatMessageRole.Assistant => ChatRole.Assistant, - ChatMessageRole.Tool => ChatRole.Tool, - _ => new ChatRole(role.ToString()), - }; - - /// Converts an OpenAI finish reason to an Extensions finish reason. - private static ChatFinishReason? ToFinishReason(OpenAI.Chat.ChatFinishReason? finishReason) => - finishReason?.ToString() is not string s ? null : - finishReason switch - { - OpenAI.Chat.ChatFinishReason.Stop => ChatFinishReason.Stop, - OpenAI.Chat.ChatFinishReason.Length => ChatFinishReason.Length, - OpenAI.Chat.ChatFinishReason.ContentFilter => ChatFinishReason.ContentFilter, - OpenAI.Chat.ChatFinishReason.ToolCalls or OpenAI.Chat.ChatFinishReason.FunctionCall => ChatFinishReason.ToolCalls, - _ => new ChatFinishReason(s), - }; - - /// Converts an extensions options instance to an OpenAI options instance. - private static ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) - { - ChatCompletionOptions result = new(); - - if (options is not null) - { - result.FrequencyPenalty = options.FrequencyPenalty; - result.MaxOutputTokenCount = options.MaxOutputTokens; - result.TopP = options.TopP; - result.PresencePenalty = options.PresencePenalty; - result.Temperature = options.Temperature; -#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) - { - foreach (string stopSequence in stopSequences) - { - result.StopSequences.Add(stopSequence); - } - } - - if (options.AdditionalProperties is { Count: > 0 } additionalProperties) - { - if (additionalProperties.TryGetValue(nameof(result.EndUserId), out string? endUserId)) - { - result.EndUserId = endUserId; - } - - if (additionalProperties.TryGetValue(nameof(result.IncludeLogProbabilities), out bool includeLogProbabilities)) - { - result.IncludeLogProbabilities = includeLogProbabilities; - } - - if (additionalProperties.TryGetValue(nameof(result.LogitBiases), out IDictionary? logitBiases)) - { - foreach (KeyValuePair kvp in logitBiases!) - { - result.LogitBiases[kvp.Key] = kvp.Value; - } - } - - if (additionalProperties.TryGetValue(nameof(result.AllowParallelToolCalls), out bool allowParallelToolCalls)) - { - result.AllowParallelToolCalls = allowParallelToolCalls; - } - - if (additionalProperties.TryGetValue(nameof(result.TopLogProbabilityCount), out int topLogProbabilityCountInt)) - { - result.TopLogProbabilityCount = topLogProbabilityCountInt; - } - - if (additionalProperties.TryGetValue(nameof(result.Metadata), out IDictionary? metadata)) - { - foreach (KeyValuePair kvp in metadata) - { - result.Metadata[kvp.Key] = kvp.Value; - } - } - - if (additionalProperties.TryGetValue(nameof(result.StoredOutputEnabled), out bool storeOutputEnabled)) - { - result.StoredOutputEnabled = storeOutputEnabled; - } - } - - if (options.Tools is { Count: > 0 } tools) - { - foreach (AITool tool in tools) - { - if (tool is AIFunction af) - { - result.Tools.Add(ToOpenAIChatTool(af)); - } - } - - switch (options.ToolMode) - { - case AutoChatToolMode: - result.ToolChoice = ChatToolChoice.CreateAutoChoice(); - break; - - case RequiredChatToolMode required: - result.ToolChoice = required.RequiredFunctionName is null ? - ChatToolChoice.CreateRequiredChoice() : - ChatToolChoice.CreateFunctionChoice(required.RequiredFunctionName); - break; - } - } - - if (options.ResponseFormat is ChatResponseFormatText) - { - result.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); - } - else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) - { - result.ResponseFormat = jsonFormat.Schema is { } jsonSchema ? - OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes( - JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription) : - OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); - } - } - - return result; - } - - /// Converts an Extensions function to an OpenAI chat tool. - private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) - { - bool? strict = - aiFunction.Metadata.AdditionalProperties.TryGetValue("Strict", out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; - - BinaryData resultParameters = OpenAIChatToolJson.ZeroFunctionParametersSchema; - - var parameters = aiFunction.Metadata.Parameters; - if (parameters is { Count: > 0 }) - { - OpenAIChatToolJson tool = new(); - - foreach (AIFunctionParameterMetadata parameter in parameters) - { - tool.Properties.Add(parameter.Name, parameter.Schema is JsonElement e ? e : _defaultParameterSchema); - - if (parameter.IsRequired) - { - tool.Required.Add(parameter.Name); - } - } - - resultParameters = BinaryData.FromBytes( - JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.OpenAIChatToolJson)); - } - - return ChatTool.CreateFunctionTool(aiFunction.Metadata.Name, aiFunction.Metadata.Description, resultParameters, strict); - } - - /// Used to create the JSON payload for an OpenAI chat tool description. - internal sealed class OpenAIChatToolJson - { - /// Gets a singleton JSON data for empty parameters. Optimization for the reasonably common case of a parameterless function. - public static BinaryData ZeroFunctionParametersSchema { get; } = new("""{"type":"object","required":[],"properties":{}}"""u8.ToArray()); - - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public List Required { get; set; } = []; - - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; - } - - /// Creates an from a . - /// The content part to convert into a content. - /// The constructed , or null if the content part could not be converted. - private static AIContent? ToAIContent(ChatMessageContentPart contentPart) - { - AIContent? aiContent = null; - - if (contentPart.Kind == ChatMessageContentPartKind.Text) - { - aiContent = new TextContent(contentPart.Text); - } - else if (contentPart.Kind == ChatMessageContentPartKind.Image) - { - ImageContent? imageContent; - aiContent = imageContent = - contentPart.ImageUri is not null ? new ImageContent(contentPart.ImageUri, contentPart.ImageBytesMediaType) : - contentPart.ImageBytes is not null ? new ImageContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : - null; - - if (imageContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) - { - (imageContent.AdditionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; - } - } - - if (aiContent is not null) - { - if (contentPart.Refusal is string refusal) - { - (aiContent.AdditionalProperties ??= [])[nameof(contentPart.Refusal)] = refusal; - } - - aiContent.RawRepresentation = contentPart; - } - - return aiContent; - } - - /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. - private IEnumerable ToOpenAIChatMessages(IEnumerable inputs) - { - // Maps all of the M.E.AI types to the corresponding OpenAI types. - // Unrecognized or non-processable content is ignored. - - foreach (ChatMessage input in inputs) - { - if (input.Role == ChatRole.System || input.Role == ChatRole.User) - { - var parts = GetContentParts(input.Contents); - yield return input.Role == ChatRole.System ? - new SystemChatMessage(parts) { ParticipantName = input.AuthorName } : - new UserChatMessage(parts) { ParticipantName = input.AuthorName }; - } - else if (input.Role == ChatRole.Tool) - { - foreach (AIContent item in input.Contents) - { - if (item is FunctionResultContent resultContent) - { - string? result = resultContent.Result as string; - if (result is null && resultContent.Result is not null) - { - try - { - result = JsonSerializer.Serialize(resultContent.Result, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); - } - catch (NotSupportedException) - { - // If the type can't be serialized, skip it. - } - } - - yield return new ToolChatMessage(resultContent.CallId, result ?? string.Empty); - } - } - } - else if (input.Role == ChatRole.Assistant) - { - AssistantChatMessage message = new(GetContentParts(input.Contents)) - { - ParticipantName = input.AuthorName - }; - - foreach (var content in input.Contents) - { - if (content is FunctionCallContent { CallId: not null } callRequest) - { - message.ToolCalls.Add( - ChatToolCall.CreateFunctionToolCall( - callRequest.CallId, - callRequest.Name, - new(JsonSerializer.SerializeToUtf8Bytes( - callRequest.Arguments, - ToolCallJsonSerializerOptions.GetTypeInfo(typeof(IDictionary)))))); - } - } - - if (input.AdditionalProperties?.TryGetValue(nameof(message.Refusal), out string? refusal) is true) - { - message.Refusal = refusal; - } - - yield return message; - } - } - } - - /// Converts a list of to a list of . - private static List GetContentParts(IList contents) - { - List parts = []; - foreach (var content in contents) - { - switch (content) - { - case TextContent textContent: - parts.Add(ChatMessageContentPart.CreateTextPart(textContent.Text)); - break; - - case ImageContent imageContent when imageContent.Data is { IsEmpty: false } data: - parts.Add(ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(data), imageContent.MediaType)); - break; - - case ImageContent imageContent when imageContent.Uri is string uri: - parts.Add(ChatMessageContentPart.CreateImagePart(new Uri(uri))); - break; - } - } - - if (parts.Count == 0) - { - parts.Add(ChatMessageContentPart.CreateTextPart(string.Empty)); - } - - return parts; - } - - private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, - (JsonTypeInfo>)AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))!); - - private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, - (JsonTypeInfo>)AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))!); } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatCompletionRequest.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatCompletionRequest.cs new file mode 100644 index 00000000000..dba0e5ecbf8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatCompletionRequest.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.Collections.Generic; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an OpenAI chat completion request deserialized as Microsoft.Extension.AI models. +/// +public sealed class OpenAIChatCompletionRequest +{ + /// + /// Gets the chat messages specified in the completion request. + /// + public required IList Messages { get; init; } + + /// + /// Gets the chat options governing the completion request. + /// + public required ChatOptions Options { get; init; } + + /// + /// Gets a value indicating whether the completion response should be streamed. + /// + public bool Stream { get; init; } + + /// + /// Gets the model id requested by the chat completion. + /// + public string? ModelId { get; init; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs index 9cd075e1d04..69f610b4818 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.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; using System.Text.Json.Serialization; @@ -11,6 +12,7 @@ namespace Microsoft.Extensions.AI; UseStringEnumConverter = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true)] -[JsonSerializable(typeof(OpenAIChatClient.OpenAIChatToolJson))] [JsonSerializable(typeof(OpenAIRealtimeExtensions.ConversationFunctionToolParametersSchema))] +[JsonSerializable(typeof(OpenAIModelMappers.OpenAIChatToolJson))] +[JsonSerializable(typeof(IDictionary))] internal sealed partial class OpenAIJsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs new file mode 100644 index 00000000000..9f35727cf80 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatCompletion.cs @@ -0,0 +1,610 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using OpenAI.Chat; + +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable S103 // Lines should not be too long +#pragma warning disable CA1859 // Use concrete types when possible for improved performance +#pragma warning disable S1067 // Expressions should not be too complex + +namespace Microsoft.Extensions.AI; + +internal static partial class OpenAIModelMappers +{ + private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement; + + public static OpenAI.Chat.ChatCompletion ToOpenAIChatCompletion(ChatCompletion chatCompletion, JsonSerializerOptions options) + { + _ = Throw.IfNull(chatCompletion); + + if (chatCompletion.Choices.Count > 1) + { + throw new NotSupportedException("Creating OpenAI ChatCompletion models with multiple choices is currently not supported."); + } + + List? toolCalls = null; + foreach (AIContent content in chatCompletion.Message.Contents) + { + if (content is FunctionCallContent callRequest) + { + toolCalls ??= []; + toolCalls.Add(ChatToolCall.CreateFunctionToolCall( + callRequest.CallId, + callRequest.Name, + new(JsonSerializer.SerializeToUtf8Bytes( + callRequest.Arguments, + options.GetTypeInfo(typeof(IDictionary)))))); + } + } + + OpenAI.Chat.ChatTokenUsage? chatTokenUsage = null; + if (chatCompletion.Usage is UsageDetails usageDetails) + { + chatTokenUsage = ToOpenAIUsage(usageDetails); + } + + return OpenAIChatModelFactory.ChatCompletion( + id: chatCompletion.CompletionId, + model: chatCompletion.ModelId, + createdAt: chatCompletion.CreatedAt ?? default, + role: ToOpenAIChatRole(chatCompletion.Message.Role).Value, + finishReason: ToOpenAIFinishReason(chatCompletion.FinishReason), + content: new(ToOpenAIChatContent(chatCompletion.Message.Contents)), + toolCalls: toolCalls, + refusal: chatCompletion.AdditionalProperties.GetValueOrDefault(nameof(OpenAI.Chat.ChatCompletion.Refusal)), + contentTokenLogProbabilities: chatCompletion.AdditionalProperties.GetValueOrDefault>(nameof(OpenAI.Chat.ChatCompletion.ContentTokenLogProbabilities)), + refusalTokenLogProbabilities: chatCompletion.AdditionalProperties.GetValueOrDefault>(nameof(OpenAI.Chat.ChatCompletion.RefusalTokenLogProbabilities)), + systemFingerprint: chatCompletion.AdditionalProperties.GetValueOrDefault(nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)), + usage: chatTokenUsage); + } + + public static ChatCompletion FromOpenAIChatCompletion(OpenAI.Chat.ChatCompletion openAICompletion, ChatOptions? options) + { + _ = Throw.IfNull(openAICompletion); + + // Create the return message. + ChatMessage returnMessage = new() + { + RawRepresentation = openAICompletion, + Role = FromOpenAIChatRole(openAICompletion.Role), + }; + + // Populate its content from those in the OpenAI response content. + foreach (ChatMessageContentPart contentPart in openAICompletion.Content) + { + if (ToAIContent(contentPart) is AIContent aiContent) + { + returnMessage.Contents.Add(aiContent); + } + } + + // 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) + { + if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) + { + var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + callContent.RawRepresentation = toolCall; + + returnMessage.Contents.Add(callContent); + } + } + } + + // Wrap the content in a ChatCompletion to return. + var completion = new ChatCompletion([returnMessage]) + { + RawRepresentation = openAICompletion, + CompletionId = openAICompletion.Id, + CreatedAt = openAICompletion.CreatedAt, + ModelId = openAICompletion.Model, + FinishReason = FromOpenAIFinishReason(openAICompletion.FinishReason), + }; + + if (openAICompletion.Usage is ChatTokenUsage tokenUsage) + { + completion.Usage = FromOpenAIUsage(tokenUsage); + } + + if (openAICompletion.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) + { + (completion.AdditionalProperties ??= [])[nameof(openAICompletion.ContentTokenLogProbabilities)] = contentTokenLogProbs; + } + + if (openAICompletion.Refusal is string refusal) + { + (completion.AdditionalProperties ??= [])[nameof(openAICompletion.Refusal)] = refusal; + } + + if (openAICompletion.RefusalTokenLogProbabilities is { Count: > 0 } refusalTokenLogProbs) + { + (completion.AdditionalProperties ??= [])[nameof(openAICompletion.RefusalTokenLogProbabilities)] = refusalTokenLogProbs; + } + + if (openAICompletion.SystemFingerprint is string systemFingerprint) + { + (completion.AdditionalProperties ??= [])[nameof(openAICompletion.SystemFingerprint)] = systemFingerprint; + } + + return completion; + } + + public static ChatOptions FromOpenAIOptions(OpenAI.Chat.ChatCompletionOptions? options) + { + ChatOptions result = new(); + + if (options is not null) + { + result.ModelId = _getModelIdAccessor.Invoke(options, null)?.ToString(); + result.FrequencyPenalty = options.FrequencyPenalty; + result.MaxOutputTokens = options.MaxOutputTokenCount; + result.TopP = options.TopP; + result.PresencePenalty = options.PresencePenalty; + result.Temperature = options.Temperature; +#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) + { + result.StopSequences = [.. stopSequences]; + } + + if (options.EndUserId is string endUserId) + { + (result.AdditionalProperties ??= [])[nameof(options.EndUserId)] = endUserId; + } + + if (options.IncludeLogProbabilities is bool includeLogProbabilities) + { + (result.AdditionalProperties ??= [])[nameof(options.IncludeLogProbabilities)] = includeLogProbabilities; + } + + if (options.LogitBiases is { Count: > 0 } logitBiases) + { + (result.AdditionalProperties ??= [])[nameof(options.LogitBiases)] = new Dictionary(logitBiases); + } + + if (options.AllowParallelToolCalls is bool allowParallelToolCalls) + { + (result.AdditionalProperties ??= [])[nameof(options.AllowParallelToolCalls)] = allowParallelToolCalls; + } + + if (options.TopLogProbabilityCount is int topLogProbabilityCount) + { + (result.AdditionalProperties ??= [])[nameof(options.TopLogProbabilityCount)] = topLogProbabilityCount; + } + + if (options.Metadata is IDictionary { Count: > 0 } metadata) + { + (result.AdditionalProperties ??= [])[nameof(options.Metadata)] = new Dictionary(metadata); + } + + if (options.StoredOutputEnabled is bool storedOutputEnabled) + { + (result.AdditionalProperties ??= [])[nameof(options.StoredOutputEnabled)] = storedOutputEnabled; + } + + if (options.Tools is { Count: > 0 } tools) + { + foreach (ChatTool tool in tools) + { + result.Tools ??= []; + result.Tools.Add(FromOpenAIChatTool(tool)); + } + + using var toolChoiceJson = JsonDocument.Parse(JsonModelHelpers.Serialize(options.ToolChoice).ToMemory()); + JsonElement jsonElement = toolChoiceJson.RootElement; + switch (jsonElement.ValueKind) + { + case JsonValueKind.String: + result.ToolMode = jsonElement.GetString() switch + { + "required" => ChatToolMode.RequireAny, + _ => ChatToolMode.Auto, + }; + + break; + case JsonValueKind.Object: + if (jsonElement.TryGetProperty("function", out JsonElement functionElement)) + { + result.ToolMode = ChatToolMode.RequireSpecific(functionElement.GetString()!); + } + + break; + } + } + } + + return result; + } + + /// Converts an extensions options instance to an OpenAI options instance. + public static OpenAI.Chat.ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) + { + ChatCompletionOptions result = new(); + + if (options is not null) + { + result.FrequencyPenalty = options.FrequencyPenalty; + result.MaxOutputTokenCount = options.MaxOutputTokens; + result.TopP = options.TopP; + result.PresencePenalty = options.PresencePenalty; + result.Temperature = options.Temperature; +#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) + { + foreach (string stopSequence in stopSequences) + { + result.StopSequences.Add(stopSequence); + } + } + + if (options.AdditionalProperties is { Count: > 0 } additionalProperties) + { + if (additionalProperties.TryGetValue(nameof(result.EndUserId), out string? endUserId)) + { + result.EndUserId = endUserId; + } + + if (additionalProperties.TryGetValue(nameof(result.IncludeLogProbabilities), out bool includeLogProbabilities)) + { + result.IncludeLogProbabilities = includeLogProbabilities; + } + + if (additionalProperties.TryGetValue(nameof(result.LogitBiases), out IDictionary? logitBiases)) + { + foreach (KeyValuePair kvp in logitBiases!) + { + result.LogitBiases[kvp.Key] = kvp.Value; + } + } + + if (additionalProperties.TryGetValue(nameof(result.AllowParallelToolCalls), out bool allowParallelToolCalls)) + { + result.AllowParallelToolCalls = allowParallelToolCalls; + } + + if (additionalProperties.TryGetValue(nameof(result.TopLogProbabilityCount), out int topLogProbabilityCountInt)) + { + result.TopLogProbabilityCount = topLogProbabilityCountInt; + } + + if (additionalProperties.TryGetValue(nameof(result.Metadata), out IDictionary? metadata)) + { + foreach (KeyValuePair kvp in metadata) + { + result.Metadata[kvp.Key] = kvp.Value; + } + } + + if (additionalProperties.TryGetValue(nameof(result.StoredOutputEnabled), out bool storeOutputEnabled)) + { + result.StoredOutputEnabled = storeOutputEnabled; + } + } + + if (options.Tools is { Count: > 0 } tools) + { + foreach (AITool tool in tools) + { + if (tool is AIFunction af) + { + result.Tools.Add(ToOpenAIChatTool(af)); + } + } + + switch (options.ToolMode) + { + case AutoChatToolMode: + result.ToolChoice = ChatToolChoice.CreateAutoChoice(); + break; + + case RequiredChatToolMode required: + result.ToolChoice = required.RequiredFunctionName is null ? + ChatToolChoice.CreateRequiredChoice() : + ChatToolChoice.CreateFunctionChoice(required.RequiredFunctionName); + break; + } + } + + if (options.ResponseFormat is ChatResponseFormatText) + { + result.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); + } + else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) + { + result.ResponseFormat = jsonFormat.Schema is { } jsonSchema ? + OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName ?? "json_schema", + BinaryData.FromBytes( + JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription) : + OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); + } + } + + return result; + } + + private static AITool FromOpenAIChatTool(ChatTool chatTool) + { + AdditionalPropertiesDictionary additionalProperties = new(); + if (chatTool.FunctionSchemaIsStrict is bool strictValue) + { + additionalProperties["Strict"] = strictValue; + } + + OpenAIChatToolJson openAiChatTool = JsonSerializer.Deserialize(chatTool.FunctionParameters.ToMemory().Span, OpenAIJsonContext.Default.OpenAIChatToolJson)!; + List parameters = new(openAiChatTool.Properties.Count); + foreach (KeyValuePair property in openAiChatTool.Properties) + { + parameters.Add(new(property.Key) + { + Schema = property.Value, + IsRequired = openAiChatTool.Required.Contains(property.Key), + }); + } + + AIFunctionMetadata metadata = new(chatTool.FunctionName) + { + Description = chatTool.FunctionDescription, + AdditionalProperties = additionalProperties, + Parameters = parameters, + ReturnParameter = new() + { + Description = "Return parameter", + Schema = _defaultParameterSchema, + } + }; + + return new MetadataOnlyAIFunction(metadata); + } + + private sealed class MetadataOnlyAIFunction(AIFunctionMetadata metadata) : AIFunction + { + public override AIFunctionMetadata Metadata => metadata; + protected override Task InvokeCoreAsync(IEnumerable> arguments, CancellationToken cancellationToken) => + throw new InvalidOperationException($"The AI function '{metadata.Name}' does not support being invoked."); + } + + /// Converts an Extensions function to an OpenAI chat tool. + private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) + { + bool? strict = + aiFunction.Metadata.AdditionalProperties.TryGetValue("Strict", out object? strictObj) && + strictObj is bool strictValue ? + strictValue : null; + + BinaryData resultParameters = OpenAIChatToolJson.ZeroFunctionParametersSchema; + + var parameters = aiFunction.Metadata.Parameters; + if (parameters is { Count: > 0 }) + { + OpenAIChatToolJson tool = new(); + + foreach (AIFunctionParameterMetadata parameter in parameters) + { + tool.Properties.Add(parameter.Name, parameter.Schema is JsonElement e ? e : _defaultParameterSchema); + + if (parameter.IsRequired) + { + _ = tool.Required.Add(parameter.Name); + } + } + + resultParameters = BinaryData.FromBytes( + JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.OpenAIChatToolJson)); + } + + return ChatTool.CreateFunctionTool(aiFunction.Metadata.Name, aiFunction.Metadata.Description, resultParameters, strict); + } + + private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage) + { + var destination = new UsageDetails + { + InputTokenCount = tokenUsage.InputTokenCount, + OutputTokenCount = tokenUsage.OutputTokenCount, + TotalTokenCount = tokenUsage.TotalTokenCount, + AdditionalCounts = new(), + }; + + if (tokenUsage.InputTokenDetails is ChatInputTokenUsageDetails inputDetails) + { + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", + inputDetails.AudioTokenCount); + + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", + inputDetails.CachedTokenCount); + } + + if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails outputDetails) + { + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", + outputDetails.AudioTokenCount); + + destination.AdditionalCounts.Add( + $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)}", + outputDetails.ReasoningTokenCount); + } + + return destination; + } + + private static ChatTokenUsage ToOpenAIUsage(UsageDetails usageDetails) + { + ChatOutputTokenUsageDetails? outputTokenUsageDetails = null; + ChatInputTokenUsageDetails? inputTokenUsageDetails = null; + + if (usageDetails.AdditionalCounts is { Count: > 0 } additionalCounts) + { + int? inputAudioTokenCount = additionalCounts.TryGetValue( + $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", + out int value) ? value : null; + + int? inputCachedTokenCount = additionalCounts.TryGetValue( + $"{nameof(ChatTokenUsage.InputTokenDetails)}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", + out value) ? value : null; + + int? outputAudioTokenCount = additionalCounts.TryGetValue( + $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", + out value) ? value : null; + + int? outputReasoningTokenCount = additionalCounts.TryGetValue( + $"{nameof(ChatTokenUsage.OutputTokenDetails)}.{nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)}", + out value) ? value : null; + + if (inputAudioTokenCount is not null || inputCachedTokenCount is not null) + { + inputTokenUsageDetails = OpenAIChatModelFactory.ChatInputTokenUsageDetails( + audioTokenCount: inputAudioTokenCount ?? 0, + cachedTokenCount: inputCachedTokenCount ?? 0); + } + + if (outputAudioTokenCount is not null || outputReasoningTokenCount is not null) + { + outputTokenUsageDetails = OpenAIChatModelFactory.ChatOutputTokenUsageDetails( + audioTokenCount: outputAudioTokenCount ?? 0, + reasoningTokenCount: outputReasoningTokenCount ?? 0); + } + } + + return OpenAIChatModelFactory.ChatTokenUsage( + inputTokenCount: usageDetails.InputTokenCount ?? 0, + outputTokenCount: usageDetails.OutputTokenCount ?? 0, + totalTokenCount: usageDetails.TotalTokenCount ?? 0, + outputTokenDetails: outputTokenUsageDetails, + inputTokenDetails: inputTokenUsageDetails); + } + + /// Converts an OpenAI role to an Extensions role. + private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => + role switch + { + ChatMessageRole.System => ChatRole.System, + ChatMessageRole.User => ChatRole.User, + ChatMessageRole.Assistant => ChatRole.Assistant, + ChatMessageRole.Tool => ChatRole.Tool, + _ => new ChatRole(role.ToString()), + }; + + /// Converts an Extensions role to an OpenAI role. + [return: NotNullIfNotNull("role")] + private static ChatMessageRole? ToOpenAIChatRole(ChatRole? role) => + role is null ? null : + role == ChatRole.System ? ChatMessageRole.System : + role == ChatRole.User ? ChatMessageRole.User : + role == ChatRole.Assistant ? ChatMessageRole.Assistant : + role == ChatRole.Tool ? ChatMessageRole.Tool : ChatMessageRole.User; + + /// Creates an from a . + /// The content part to convert into a content. + /// The constructed , or null if the content part could not be converted. + private static AIContent? ToAIContent(ChatMessageContentPart contentPart) + { + AIContent? aiContent = null; + + if (contentPart.Kind == ChatMessageContentPartKind.Text) + { + aiContent = new TextContent(contentPart.Text); + } + else if (contentPart.Kind == ChatMessageContentPartKind.Image) + { + ImageContent? imageContent; + aiContent = imageContent = + contentPart.ImageUri is not null ? new ImageContent(contentPart.ImageUri, contentPart.ImageBytesMediaType) : + contentPart.ImageBytes is not null ? new ImageContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : + null; + + if (imageContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) + { + (imageContent.AdditionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; + } + } + + if (aiContent is not null) + { + if (contentPart.Refusal is string refusal) + { + (aiContent.AdditionalProperties ??= [])[nameof(contentPart.Refusal)] = refusal; + } + + aiContent.RawRepresentation = contentPart; + } + + return aiContent; + } + + /// Converts an OpenAI finish reason to an Extensions finish reason. + private static ChatFinishReason? FromOpenAIFinishReason(OpenAI.Chat.ChatFinishReason? finishReason) => + finishReason?.ToString() is not string s ? null : + finishReason switch + { + OpenAI.Chat.ChatFinishReason.Stop => ChatFinishReason.Stop, + OpenAI.Chat.ChatFinishReason.Length => ChatFinishReason.Length, + OpenAI.Chat.ChatFinishReason.ContentFilter => ChatFinishReason.ContentFilter, + OpenAI.Chat.ChatFinishReason.ToolCalls or OpenAI.Chat.ChatFinishReason.FunctionCall => ChatFinishReason.ToolCalls, + _ => new ChatFinishReason(s), + }; + + /// Converts an Extensions finish reason to an OpenAI finish reason. + private static OpenAI.Chat.ChatFinishReason ToOpenAIFinishReason(ChatFinishReason? finishReason) => + finishReason == ChatFinishReason.Length ? OpenAI.Chat.ChatFinishReason.Length : + finishReason == ChatFinishReason.ContentFilter ? OpenAI.Chat.ChatFinishReason.ContentFilter : + finishReason == ChatFinishReason.ToolCalls ? OpenAI.Chat.ChatFinishReason.ToolCalls : + OpenAI.Chat.ChatFinishReason.Stop; + + 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)!); + + private static T? GetValueOrDefault(this AdditionalPropertiesDictionary? dict, string key) => + dict?.TryGetValue(key, out T? value) is true ? value : default; + + /// Used to create the JSON payload for an OpenAI chat tool description. + public sealed class OpenAIChatToolJson + { + /// Gets a singleton JSON data for empty parameters. Optimization for the reasonably common case of a parameterless function. + public static BinaryData ZeroFunctionParametersSchema { get; } = new("""{"type":"object","required":[],"properties":{}}"""u8.ToArray()); + + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public HashSet Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + } + + /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. + private sealed class FunctionCallInfo + { + public string? CallId; + public string? Name; + public StringBuilder? Arguments; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs new file mode 100644 index 00000000000..e8193df24d5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMapper.ChatMessage.cs @@ -0,0 +1,255 @@ +// 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 SA1204 // Static elements should appear before instance elements + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using OpenAI.Chat; + +namespace Microsoft.Extensions.AI; + +internal static partial class OpenAIModelMappers +{ + public static OpenAIChatCompletionRequest FromOpenAIChatCompletionRequest(OpenAI.Chat.ChatCompletionOptions chatCompletionOptions) + { + ChatOptions chatOptions = FromOpenAIOptions(chatCompletionOptions); + IList messages = FromOpenAIChatMessages(_getMessagesAccessor(chatCompletionOptions)).ToList(); + return new() + { + Messages = messages, + Options = chatOptions, + ModelId = chatOptions.ModelId, + Stream = _getStreamAccessor(chatCompletionOptions) ?? false, + }; + } + + public static IEnumerable FromOpenAIChatMessages(IEnumerable inputs) + { + // Maps all of the OpenAI types to the corresponding M.E.AI types. + // Unrecognized or non-processable content is ignored. + + Dictionary? functionCalls = null; + + foreach (OpenAI.Chat.ChatMessage input in inputs) + { + switch (input) + { + case SystemChatMessage systemMessage: + yield return new ChatMessage + { + Role = ChatRole.System, + AuthorName = systemMessage.ParticipantName, + Contents = FromOpenAIChatContent(systemMessage.Content), + }; + break; + + case UserChatMessage userMessage: + yield return new ChatMessage + { + Role = ChatRole.User, + AuthorName = userMessage.ParticipantName, + Contents = FromOpenAIChatContent(userMessage.Content), + }; + break; + + case ToolChatMessage toolMessage: + string textContent = string.Join(string.Empty, toolMessage.Content.Where(part => part.Kind is ChatMessageContentPartKind.Text).Select(part => part.Text)); + object? result = textContent; + if (!string.IsNullOrEmpty(textContent)) + { +#pragma warning disable CA1031 // Do not catch general exception types + try + { + result = JsonSerializer.Deserialize(textContent, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + } + catch + { + // If the content can't be deserialized, leave it as a string. + } +#pragma warning restore CA1031 // Do not catch general exception types + } + + string functionName = functionCalls?.TryGetValue(toolMessage.ToolCallId, out string? name) is true ? name : string.Empty; + yield return new ChatMessage + { + Role = ChatRole.Tool, + Contents = new AIContent[] { new FunctionResultContent(toolMessage.ToolCallId, functionName, result) }, + }; + break; + + case AssistantChatMessage assistantMessage: + + ChatMessage message = new() + { + Role = ChatRole.Assistant, + AuthorName = assistantMessage.ParticipantName, + Contents = FromOpenAIChatContent(assistantMessage.Content), + }; + + foreach (ChatToolCall toolCall in assistantMessage.ToolCalls) + { + if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) + { + var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + callContent.RawRepresentation = toolCall; + + message.Contents.Add(callContent); + (functionCalls ??= new()).Add(toolCall.Id, toolCall.FunctionName); + } + } + + if (assistantMessage.Refusal is not null) + { + message.AdditionalProperties ??= []; + message.AdditionalProperties.Add(nameof(assistantMessage.Refusal), assistantMessage.Refusal); + } + + yield return message; + break; + } + } + } + + /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. + public static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, JsonSerializerOptions options) + { + // Maps all of the M.E.AI types to the corresponding OpenAI types. + // Unrecognized or non-processable content is ignored. + + foreach (ChatMessage input in inputs) + { + if (input.Role == ChatRole.System || input.Role == ChatRole.User) + { + var parts = ToOpenAIChatContent(input.Contents); + yield return input.Role == ChatRole.System ? + new SystemChatMessage(parts) { ParticipantName = input.AuthorName } : + new UserChatMessage(parts) { ParticipantName = input.AuthorName }; + } + else if (input.Role == ChatRole.Tool) + { + foreach (AIContent item in input.Contents) + { + if (item is FunctionResultContent resultContent) + { + string? result = resultContent.Result as string; + if (result is null && resultContent.Result is not null) + { + try + { + result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object))); + } + catch (NotSupportedException) + { + // If the type can't be serialized, skip it. + } + } + + yield return new ToolChatMessage(resultContent.CallId, result ?? string.Empty); + } + } + } + else if (input.Role == ChatRole.Assistant) + { + AssistantChatMessage message = new(ToOpenAIChatContent(input.Contents)) + { + ParticipantName = input.AuthorName + }; + + foreach (var content in input.Contents) + { + if (content is FunctionCallContent callRequest) + { + message.ToolCalls.Add( + ChatToolCall.CreateFunctionToolCall( + callRequest.CallId, + callRequest.Name, + new(JsonSerializer.SerializeToUtf8Bytes( + callRequest.Arguments, + options.GetTypeInfo(typeof(IDictionary)))))); + } + } + + if (input.AdditionalProperties?.TryGetValue(nameof(message.Refusal), out string? refusal) is true) + { + message.Refusal = refusal; + } + + yield return message; + } + } + } + + private static List FromOpenAIChatContent(IList openAiMessageContentParts) + { + List contents = new(); + foreach (var openAiContentPart in openAiMessageContentParts) + { + switch (openAiContentPart.Kind) + { + case ChatMessageContentPartKind.Text: + contents.Add(new TextContent(openAiContentPart.Text)); + break; + + case ChatMessageContentPartKind.Image when (openAiContentPart.ImageBytes is { } bytes): + contents.Add(new ImageContent(bytes.ToArray(), openAiContentPart.ImageBytesMediaType)); + break; + + case ChatMessageContentPartKind.Image: + contents.Add(new ImageContent(openAiContentPart.ImageUri?.ToString() ?? string.Empty)); + break; + + } + } + + return contents; + } + + /// Converts a list of to a list of . + private static List ToOpenAIChatContent(IList contents) + { + List parts = []; + foreach (var content in contents) + { + switch (content) + { + case TextContent textContent: + parts.Add(ChatMessageContentPart.CreateTextPart(textContent.Text)); + break; + + case ImageContent imageContent when imageContent.Data is { IsEmpty: false } data: + parts.Add(ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(data), imageContent.MediaType)); + break; + + case ImageContent imageContent when imageContent.Uri is string uri: + parts.Add(ChatMessageContentPart.CreateImagePart(new Uri(uri))); + break; + } + } + + if (parts.Count == 0) + { + parts.Add(ChatMessageContentPart.CreateTextPart(string.Empty)); + } + + return parts; + } + +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + private static readonly Func> _getMessagesAccessor = + (Func>) + typeof(ChatCompletionOptions).GetMethod("get_Messages", BindingFlags.NonPublic | BindingFlags.Instance)! + .CreateDelegate(typeof(Func>))!; + + private static readonly Func _getStreamAccessor = + (Func) + typeof(ChatCompletionOptions).GetMethod("get_Stream", BindingFlags.NonPublic | BindingFlags.Instance)! + .CreateDelegate(typeof(Func))!; + + private static readonly MethodInfo _getModelIdAccessor = + typeof(ChatCompletionOptions).GetMethod("get_Model", BindingFlags.NonPublic | BindingFlags.Instance)!; +#pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMappers.StreamingChatCompletion.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMappers.StreamingChatCompletion.cs new file mode 100644 index 00000000000..9fe6fa3fada --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIModelMappers.StreamingChatCompletion.cs @@ -0,0 +1,205 @@ +// 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 SA1204 // Static elements should appear before instance elements +#pragma warning disable S103 // Lines should not be too long +#pragma warning disable CA1859 // Use concrete types when possible for improved performance + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenAI.Chat; + +namespace Microsoft.Extensions.AI; + +internal static partial class OpenAIModelMappers +{ + public static async IAsyncEnumerable ToOpenAIStreamingChatCompletionAsync( + IAsyncEnumerable chatCompletions, + JsonSerializerOptions options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var chatCompletionUpdate in chatCompletions.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + List? toolCallUpdates = null; + ChatTokenUsage? chatTokenUsage = null; + + foreach (var content in chatCompletionUpdate.Contents) + { + if (content is FunctionCallContent functionCallContent) + { + toolCallUpdates ??= []; + toolCallUpdates.Add(OpenAIChatModelFactory.StreamingChatToolCallUpdate( + index: toolCallUpdates.Count, + toolCallId: functionCallContent.CallId, + functionName: functionCallContent.Name, + functionArgumentsUpdate: new(JsonSerializer.SerializeToUtf8Bytes(functionCallContent.Arguments, options.GetTypeInfo(typeof(IDictionary)))))); + } + else if (content is UsageContent usageContent) + { + chatTokenUsage = ToOpenAIUsage(usageContent.Details); + } + } + + yield return OpenAIChatModelFactory.StreamingChatCompletionUpdate( + completionId: chatCompletionUpdate.CompletionId, + model: chatCompletionUpdate.ModelId, + createdAt: chatCompletionUpdate.CreatedAt ?? default, + role: ToOpenAIChatRole(chatCompletionUpdate.Role), + finishReason: ToOpenAIFinishReason(chatCompletionUpdate.FinishReason), + contentUpdate: [.. ToOpenAIChatContent(chatCompletionUpdate.Contents)], + toolCallUpdates: toolCallUpdates, + refusalUpdate: chatCompletionUpdate.AdditionalProperties.GetValueOrDefault(nameof(OpenAI.Chat.StreamingChatCompletionUpdate.RefusalUpdate)), + contentTokenLogProbabilities: chatCompletionUpdate.AdditionalProperties.GetValueOrDefault>(nameof(OpenAI.Chat.StreamingChatCompletionUpdate.ContentTokenLogProbabilities)), + refusalTokenLogProbabilities: chatCompletionUpdate.AdditionalProperties.GetValueOrDefault>(nameof(OpenAI.Chat.StreamingChatCompletionUpdate.RefusalTokenLogProbabilities)), + systemFingerprint: chatCompletionUpdate.AdditionalProperties.GetValueOrDefault(nameof(OpenAI.Chat.StreamingChatCompletionUpdate.SystemFingerprint)), + usage: chatTokenUsage); + } + } + + public static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( + IAsyncEnumerable chatCompletionUpdates, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Dictionary? functionCallInfos = null; + ChatRole? streamedRole = null; + ChatFinishReason? finishReason = null; + StringBuilder? refusal = null; + string? completionId = null; + DateTimeOffset? createdAt = null; + string? modelId = null; + string? fingerprint = null; + + // Process each update as it arrives + await foreach (OpenAI.Chat.StreamingChatCompletionUpdate chatCompletionUpdate in chatCompletionUpdates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + // The role and finish reason may arrive during any update, but once they've arrived, the same value should be the same for all subsequent updates. + streamedRole ??= chatCompletionUpdate.Role is ChatMessageRole role ? FromOpenAIChatRole(role) : null; + finishReason ??= chatCompletionUpdate.FinishReason is OpenAI.Chat.ChatFinishReason reason ? FromOpenAIFinishReason(reason) : null; + completionId ??= chatCompletionUpdate.CompletionId; + createdAt ??= chatCompletionUpdate.CreatedAt; + modelId ??= chatCompletionUpdate.Model; + fingerprint ??= chatCompletionUpdate.SystemFingerprint; + + // Create the response content object. + StreamingChatCompletionUpdate completionUpdate = new() + { + CompletionId = chatCompletionUpdate.CompletionId, + CreatedAt = chatCompletionUpdate.CreatedAt, + FinishReason = finishReason, + ModelId = modelId, + RawRepresentation = chatCompletionUpdate, + Role = streamedRole, + }; + + // Populate it with any additional metadata from the OpenAI object. + if (chatCompletionUpdate.ContentTokenLogProbabilities is { Count: > 0 } contentTokenLogProbs) + { + (completionUpdate.AdditionalProperties ??= [])[nameof(chatCompletionUpdate.ContentTokenLogProbabilities)] = contentTokenLogProbs; + } + + if (chatCompletionUpdate.RefusalTokenLogProbabilities is { Count: > 0 } refusalTokenLogProbs) + { + (completionUpdate.AdditionalProperties ??= [])[nameof(chatCompletionUpdate.RefusalTokenLogProbabilities)] = refusalTokenLogProbs; + } + + if (fingerprint is not null) + { + (completionUpdate.AdditionalProperties ??= [])[nameof(chatCompletionUpdate.SystemFingerprint)] = fingerprint; + } + + // Transfer over content update items. + if (chatCompletionUpdate.ContentUpdate is { Count: > 0 }) + { + foreach (ChatMessageContentPart contentPart in chatCompletionUpdate.ContentUpdate) + { + if (ToAIContent(contentPart) is AIContent aiContent) + { + completionUpdate.Contents.Add(aiContent); + } + } + } + + // Transfer over refusal updates. + if (chatCompletionUpdate.RefusalUpdate is not null) + { + _ = (refusal ??= new()).Append(chatCompletionUpdate.RefusalUpdate); + } + + // Transfer over tool call updates. + if (chatCompletionUpdate.ToolCallUpdates is { Count: > 0 } toolCallUpdates) + { + foreach (StreamingChatToolCallUpdate toolCallUpdate in toolCallUpdates) + { + functionCallInfos ??= []; + if (!functionCallInfos.TryGetValue(toolCallUpdate.Index, out FunctionCallInfo? existing)) + { + functionCallInfos[toolCallUpdate.Index] = existing = new(); + } + + existing.CallId ??= toolCallUpdate.ToolCallId; + existing.Name ??= toolCallUpdate.FunctionName; + if (toolCallUpdate.FunctionArgumentsUpdate is { } update && !update.ToMemory().IsEmpty) + { + _ = (existing.Arguments ??= new()).Append(update.ToString()); + } + } + } + + // Transfer over usage updates. + if (chatCompletionUpdate.Usage is ChatTokenUsage tokenUsage) + { + var usageDetails = FromOpenAIUsage(tokenUsage); + completionUpdate.Contents.Add(new UsageContent(usageDetails)); + } + + // Now yield the item. + yield return completionUpdate; + } + + // Now that we've received all updates, combine any for function calls into a single item to yield. + if (functionCallInfos is not null) + { + StreamingChatCompletionUpdate completionUpdate = new() + { + CompletionId = completionId, + CreatedAt = createdAt, + FinishReason = finishReason, + ModelId = modelId, + Role = streamedRole, + }; + + foreach (var entry in functionCallInfos) + { + FunctionCallInfo fci = entry.Value; + if (!string.IsNullOrWhiteSpace(fci.Name)) + { + var callContent = ParseCallContentFromJsonString( + fci.Arguments?.ToString() ?? string.Empty, + fci.CallId!, + fci.Name!); + completionUpdate.Contents.Add(callContent); + } + } + + // Refusals are about the model not following the schema for tool calls. As such, if we have any refusal, + // add it to this function calling item. + if (refusal is not null) + { + (completionUpdate.AdditionalProperties ??= [])[nameof(ChatMessageContentPart.Refusal)] = refusal.ToString(); + } + + // Propagate additional relevant metadata. + if (fingerprint is not null) + { + (completionUpdate.AdditionalProperties ??= [])[nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)] = fingerprint; + } + + yield return completionUpdate; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISerializationHelpers.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISerializationHelpers.cs new file mode 100644 index 00000000000..899a69630b8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISerializationHelpers.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Net.ServerSentEvents; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using OpenAI.Chat; + +namespace Microsoft.Extensions.AI; + +/// +/// Defines a set of helpers used to serialize Microsoft.Extensions.AI content using the OpenAI wire format. +/// +public static class OpenAISerializationHelpers +{ + /// + /// Deserializes a chat completion request in the OpenAI wire format into a pair of and values. + /// + /// The stream containing a message using the OpenAI wire format. + /// A token used to cancel the operation. + /// The deserialized list of chat messages and chat options. + public static async Task DeserializeChatCompletionRequestAsync( + Stream stream, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(stream); + + BinaryData binaryData = await BinaryData.FromStreamAsync(stream, cancellationToken).ConfigureAwait(false); + ChatCompletionOptions openAiChatOptions = JsonModelHelpers.Deserialize(binaryData); + return OpenAIModelMappers.FromOpenAIChatCompletionRequest(openAiChatOptions); + } + + /// + /// Serializes a Microsoft.Extensions.AI completion using the OpenAI wire format. + /// + /// The stream to write the value. + /// The chat completion to serialize. + /// The governing function call content serialization. + /// A token used to cancel the serialization operation. + /// A task tracking the serialization operation. + public static async Task SerializeAsync( + Stream stream, + ChatCompletion chatCompletion, + JsonSerializerOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(stream); + _ = Throw.IfNull(chatCompletion); + options ??= AIJsonUtilities.DefaultOptions; + + OpenAI.Chat.ChatCompletion openAiChatCompletion = OpenAIModelMappers.ToOpenAIChatCompletion(chatCompletion, options); + BinaryData binaryData = JsonModelHelpers.Serialize(openAiChatCompletion); + await stream.WriteAsync(binaryData.ToMemory(), cancellationToken).ConfigureAwait(false); + } + + /// + /// Serializes a Microsoft.Extensions.AI streaming completion using the OpenAI wire format. + /// + /// The stream to write the value. + /// The streaming chat completions to serialize. + /// The governing function call content serialization. + /// A token used to cancel the serialization operation. + /// A task tracking the serialization operation. + public static Task SerializeStreamingAsync( + Stream stream, + IAsyncEnumerable streamingChatCompletionUpdates, + JsonSerializerOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(stream); + _ = Throw.IfNull(streamingChatCompletionUpdates); + options ??= AIJsonUtilities.DefaultOptions; + + var mappedUpdates = OpenAIModelMappers.ToOpenAIStreamingChatCompletionAsync(streamingChatCompletionUpdates, options, cancellationToken); + return SseFormatter.WriteAsync(ToSseEventsAsync(mappedUpdates), stream, FormatAsSseEvent, cancellationToken); + + static async IAsyncEnumerable> ToSseEventsAsync(IAsyncEnumerable updates) + { + await foreach (var update in updates.ConfigureAwait(false)) + { + BinaryData binaryData = JsonModelHelpers.Serialize(update); + yield return new(binaryData); + } + + yield return new(_finalSseEvent); + } + + static void FormatAsSseEvent(SseItem sseItem, IBufferWriter writer) => + writer.Write(sseItem.Data.ToMemory().Span); + } + + private static readonly BinaryData _finalSseEvent = new("[DONE]"u8.ToArray()); +} diff --git a/src/Shared/ServerSentEvents/ArrayBuffer.cs b/src/Shared/ServerSentEvents/ArrayBuffer.cs new file mode 100644 index 00000000000..70331991ce7 --- /dev/null +++ b/src/Shared/ServerSentEvents/ArrayBuffer.cs @@ -0,0 +1,197 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +#pragma warning disable SA1405 // Debug.Assert should provide message text +#pragma warning disable IDE0032 // Use auto property +#pragma warning disable S3358 // Ternary operators should not be nested +#pragma warning disable SA1202 // Elements should be ordered by access +#pragma warning disable S109 // Magic numbers should not be used + +namespace System.Net +{ + // Warning: Mutable struct! + // The purpose of this struct is to simplify buffer management. + // It manages a sliding buffer where bytes can be added at the end and removed at the beginning. + // [ActiveSpan/Memory] contains the current buffer contents; these bytes will be preserved + // (copied, if necessary) on any call to EnsureAvailableBytes. + // [AvailableSpan/Memory] contains the available bytes past the end of the current content, + // and can be written to in order to add data to the end of the buffer. + // Commit(byteCount) will extend the ActiveSpan by [byteCount] bytes into the AvailableSpan. + // Discard(byteCount) will discard [byteCount] bytes as the beginning of the ActiveSpan. + + [StructLayout(LayoutKind.Auto)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal struct ArrayBuffer : IDisposable + { + private readonly bool _usePool; + private byte[] _bytes; + private int _activeStart; + private int _availableStart; + + // Invariants: + // 0 <= _activeStart <= _availableStart <= bytes.Length + + public ArrayBuffer(int initialSize, bool usePool = false) + { + Debug.Assert(initialSize > 0 || usePool); + + _usePool = usePool; + _bytes = initialSize == 0 + ? Array.Empty() + : usePool ? ArrayPool.Shared.Rent(initialSize) : new byte[initialSize]; + _activeStart = 0; + _availableStart = 0; + } + + public ArrayBuffer(byte[] buffer) + { + Debug.Assert(buffer.Length > 0); + + _usePool = false; + _bytes = buffer; + _activeStart = 0; + _availableStart = 0; + } + + public void Dispose() + { + _activeStart = 0; + _availableStart = 0; + + byte[] array = _bytes; + _bytes = null!; + + if (array is not null) + { + ReturnBufferIfPooled(array); + } + } + + // This is different from Dispose as the instance remains usable afterwards (_bytes will not be null). + public void ClearAndReturnBuffer() + { + Debug.Assert(_usePool); + Debug.Assert(_bytes is not null); + + _activeStart = 0; + _availableStart = 0; + + byte[] bufferToReturn = _bytes!; + _bytes = Array.Empty(); + ReturnBufferIfPooled(bufferToReturn); + } + + public readonly int ActiveLength => _availableStart - _activeStart; + public readonly Span ActiveSpan => new Span(_bytes, _activeStart, _availableStart - _activeStart); + public readonly ReadOnlySpan ActiveReadOnlySpan => new ReadOnlySpan(_bytes, _activeStart, _availableStart - _activeStart); + public readonly Memory ActiveMemory => new Memory(_bytes, _activeStart, _availableStart - _activeStart); + + public readonly int AvailableLength => _bytes.Length - _availableStart; + public readonly Span AvailableSpan => _bytes.AsSpan(_availableStart); + public readonly Memory AvailableMemory => _bytes.AsMemory(_availableStart); + public readonly Memory AvailableMemorySliced(int length) => new Memory(_bytes, _availableStart, length); + + public readonly int Capacity => _bytes.Length; + public readonly int ActiveStartOffset => _activeStart; + + public readonly byte[] DangerousGetUnderlyingBuffer() => _bytes; + + public void Discard(int byteCount) + { + Debug.Assert(byteCount <= ActiveLength, $"Expected {byteCount} <= {ActiveLength}"); + _activeStart += byteCount; + + if (_activeStart == _availableStart) + { + _activeStart = 0; + _availableStart = 0; + } + } + + public void Commit(int byteCount) + { + Debug.Assert(byteCount <= AvailableLength); + _availableStart += byteCount; + } + + // Ensure at least [byteCount] bytes to write to. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void EnsureAvailableSpace(int byteCount) + { + if (byteCount > AvailableLength) + { + EnsureAvailableSpaceCore(byteCount); + } + } + + private void EnsureAvailableSpaceCore(int byteCount) + { + Debug.Assert(AvailableLength < byteCount); + + if (_bytes.Length == 0) + { + Debug.Assert(_usePool && _activeStart == 0 && _availableStart == 0); + _bytes = ArrayPool.Shared.Rent(byteCount); + return; + } + + int totalFree = _activeStart + AvailableLength; + if (byteCount <= totalFree) + { + // We can free up enough space by just shifting the bytes down, so do so. + Buffer.BlockCopy(_bytes, _activeStart, _bytes, 0, ActiveLength); + _availableStart = ActiveLength; + _activeStart = 0; + Debug.Assert(byteCount <= AvailableLength); + return; + } + + // Double the size of the buffer until we have enough space. + int desiredSize = ActiveLength + byteCount; + int newSize = _bytes.Length; + do + { + newSize *= 2; + } + while (newSize < desiredSize); + + byte[] newBytes = _usePool ? + ArrayPool.Shared.Rent(newSize) : + new byte[newSize]; + byte[] oldBytes = _bytes; + + if (ActiveLength != 0) + { + Buffer.BlockCopy(oldBytes, _activeStart, newBytes, 0, ActiveLength); + } + + _availableStart = ActiveLength; + _activeStart = 0; + + _bytes = newBytes; + ReturnBufferIfPooled(oldBytes); + + Debug.Assert(byteCount <= AvailableLength); + } + + public void Grow() + { + EnsureAvailableSpaceCore(AvailableLength + 1); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private readonly void ReturnBufferIfPooled(byte[] buffer) + { + // The buffer may be Array.Empty() + if (_usePool && buffer.Length > 0) + { + ArrayPool.Shared.Return(buffer); + } + } + } +} diff --git a/src/Shared/ServerSentEvents/Helpers.cs b/src/Shared/ServerSentEvents/Helpers.cs new file mode 100644 index 00000000000..c976162fa1e --- /dev/null +++ b/src/Shared/ServerSentEvents/Helpers.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +#if !NET +using System.IO; +using System.Runtime.InteropServices; +#endif +using System.Text; +#if !NET +using System.Threading; +using System.Threading.Tasks; +#endif + +#pragma warning disable SA1405 // Debug.Assert should provide message text +#pragma warning disable S2333 // Redundant modifiers should not be used +#pragma warning disable SA1519 // Braces should not be omitted from multi-line child statement +#pragma warning disable LA0001 // Use Microsoft.Shared.Diagnostics.Throws for improved performance +#pragma warning disable LA0002 // Use Microsoft.Shared.Diagnostics.ToInvariantString for improved performance + +namespace System.Net.ServerSentEvents +{ + [EditorBrowsable(EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static class Helpers + { + public static void WriteUtf8Number(this IBufferWriter writer, long value) + { +#if NET + const int MaxDecimalDigits = 20; + Span buffer = writer.GetSpan(MaxDecimalDigits); + Debug.Assert(buffer.Length >= MaxDecimalDigits); + + bool success = value.TryFormat(buffer, out int bytesWritten, provider: CultureInfo.InvariantCulture); + Debug.Assert(success); + writer.Advance(bytesWritten); +#else + writer.WriteUtf8String(value.ToString(CultureInfo.InvariantCulture).AsSpan()); +#endif + } + + public static void WriteUtf8String(this IBufferWriter writer, ReadOnlySpan value) + { + if (value.IsEmpty) + { + return; + } + + Span buffer = writer.GetSpan(value.Length); + Debug.Assert(value.Length <= buffer.Length); + value.CopyTo(buffer); + writer.Advance(value.Length); + } + + public static unsafe void WriteUtf8String(this IBufferWriter writer, ReadOnlySpan value) + { + if (value.IsEmpty) + { + return; + } + + int maxByteCount = Encoding.UTF8.GetMaxByteCount(value.Length); + Span buffer = writer.GetSpan(maxByteCount); + Debug.Assert(maxByteCount <= buffer.Length); + int bytesWritten; +#if NET + bytesWritten = Encoding.UTF8.GetBytes(value, buffer); +#else + fixed (char* chars = value) + fixed (byte* bytes = buffer) + { + bytesWritten = Encoding.UTF8.GetBytes(chars, value.Length, bytes, maxByteCount); + } +#endif + writer.Advance(bytesWritten); + } + + public static bool ContainsLineBreaks(this ReadOnlySpan text) => + text.IndexOfAny('\r', '\n') >= 0; + +#if !NET + + public static ValueTask WriteAsync(this Stream stream, ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (MemoryMarshal.TryGetArray(buffer, out ArraySegment segment)) + { + return new ValueTask(stream.WriteAsync(segment.Array, segment.Offset, segment.Count, cancellationToken)); + } + else + { + return WriteAsyncUsingPooledBuffer(stream, buffer, cancellationToken); + + static async ValueTask WriteAsyncUsingPooledBuffer(Stream stream, ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + byte[] sharedBuffer = ArrayPool.Shared.Rent(buffer.Length); + buffer.Span.CopyTo(sharedBuffer); + try + { + await stream.WriteAsync(sharedBuffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(sharedBuffer); + } + } + } + } +#endif + + public static unsafe string Utf8GetString(ReadOnlySpan bytes) + { +#if NET + return Encoding.UTF8.GetString(bytes); +#else + fixed (byte* ptr = bytes) + { + return ptr is null ? + string.Empty : + Encoding.UTF8.GetString(ptr, bytes.Length); + } +#endif + } + } +} diff --git a/src/Shared/ServerSentEvents/PooledByteBufferWriter.cs b/src/Shared/ServerSentEvents/PooledByteBufferWriter.cs new file mode 100644 index 00000000000..0c03d4fe91a --- /dev/null +++ b/src/Shared/ServerSentEvents/PooledByteBufferWriter.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.ComponentModel; + +namespace System.Net.ServerSentEvents +{ + [EditorBrowsable(EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal sealed class PooledByteBufferWriter : IBufferWriter, IDisposable + { + private const int MinimumBufferSize = 256; + private ArrayBuffer _buffer = new(initialSize: 256, usePool: true); + + public void Advance(int count) => _buffer.Commit(count); + + public Memory GetMemory(int sizeHint = 0) + { + _buffer.EnsureAvailableSpace(Math.Max(sizeHint, MinimumBufferSize)); + return _buffer.AvailableMemory; + } + + public Span GetSpan(int sizeHint = 0) + { + _buffer.EnsureAvailableSpace(Math.Max(sizeHint, MinimumBufferSize)); + return _buffer.AvailableSpan; + } + + public ReadOnlyMemory WrittenMemory => _buffer.ActiveMemory; + public int Capacity => _buffer.Capacity; + public int WrittenCount => _buffer.ActiveLength; + public void Reset() => _buffer.Discard(_buffer.ActiveLength); + public void Dispose() => _buffer.Dispose(); + } +} diff --git a/src/Shared/ServerSentEvents/README.md b/src/Shared/ServerSentEvents/README.md new file mode 100644 index 00000000000..5afb7ad6627 --- /dev/null +++ b/src/Shared/ServerSentEvents/README.md @@ -0,0 +1,11 @@ +# System.Net.ServerSentEvents + +Polyfill for the System.Net.ServerSentEvents library, including the `SseFormatter` component available in .NET 10. + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/src/Shared/ServerSentEvents/SseFormatter.cs b/src/Shared/ServerSentEvents/SseFormatter.cs new file mode 100644 index 00000000000..929d7672ec9 --- /dev/null +++ b/src/Shared/ServerSentEvents/SseFormatter.cs @@ -0,0 +1,169 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable SA1405 // Debug.Assert should provide message text + +namespace System.Net.ServerSentEvents +{ + /// + /// Provides methods for formatting server-sent events. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static class SseFormatter + { + private static readonly byte[] _newLine = "\n"u8.ToArray(); + + /// + /// Writes the of server-sent events to the stream. + /// + /// The events to write to the stream. + /// The destination stream to write the events. + /// The that can be used to cancel the write operation. + /// A task that represents the asynchronous write operation. + public static Task WriteAsync(IAsyncEnumerable> source, Stream destination, CancellationToken cancellationToken = default) + { + if (source is null) + { + ThrowHelper.ThrowArgumentNullException(nameof(source)); + } + + if (destination is null) + { + ThrowHelper.ThrowArgumentNullException(nameof(destination)); + } + + return WriteAsyncCore(source, destination, static (item, writer) => writer.WriteUtf8String(item.Data.AsSpan()), cancellationToken); + } + + /// + /// Writes the of server-sent events to the stream. + /// + /// The data type of the event. + /// The events to write to the stream. + /// The destination stream to write the events. + /// The formatter for the data field of given event. + /// The that can be used to cancel the write operation. + /// A task that represents the asynchronous write operation. + public static Task WriteAsync(IAsyncEnumerable> source, Stream destination, Action, IBufferWriter> itemFormatter, CancellationToken cancellationToken = default) + { + if (source is null) + { + ThrowHelper.ThrowArgumentNullException(nameof(source)); + } + + if (destination is null) + { + ThrowHelper.ThrowArgumentNullException(nameof(destination)); + } + + if (itemFormatter is null) + { + ThrowHelper.ThrowArgumentNullException(nameof(itemFormatter)); + } + + return WriteAsyncCore(source, destination, itemFormatter, cancellationToken); + } + + private static async Task WriteAsyncCore(IAsyncEnumerable> source, Stream destination, Action, IBufferWriter> itemFormatter, CancellationToken cancellationToken) + { + using PooledByteBufferWriter bufferWriter = new(); + using PooledByteBufferWriter userDataBufferWriter = new(); + + await foreach (SseItem item in source.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + itemFormatter(item, userDataBufferWriter); + + FormatSseEvent( + bufferWriter, + eventType: item._eventType, // Do not use the public property since it normalizes to "message" if null + data: userDataBufferWriter.WrittenMemory.Span, + eventId: item.EventId, + reconnectionInterval: item.ReconnectionInterval); + + await destination.WriteAsync(bufferWriter.WrittenMemory, cancellationToken).ConfigureAwait(false); + + userDataBufferWriter.Reset(); + bufferWriter.Reset(); + } + } + + private static void FormatSseEvent( + PooledByteBufferWriter bufferWriter, + string? eventType, + ReadOnlySpan data, + string? eventId, + TimeSpan? reconnectionInterval) + { + Debug.Assert(bufferWriter.WrittenCount is 0); + + if (eventType is not null) + { + Debug.Assert(!eventType.AsSpan().ContainsLineBreaks()); + + bufferWriter.WriteUtf8String("event: "u8); + bufferWriter.WriteUtf8String(eventType.AsSpan()); + bufferWriter.WriteUtf8String(_newLine); + } + + WriteLinesWithPrefix(bufferWriter, prefix: "data: "u8, data); + bufferWriter.Write(_newLine); + + if (eventId is not null) + { + Debug.Assert(!eventId.AsSpan().ContainsLineBreaks()); + + bufferWriter.WriteUtf8String("id: "u8); + bufferWriter.WriteUtf8String(eventId.AsSpan()); + bufferWriter.WriteUtf8String(_newLine); + } + + if (reconnectionInterval is { } retry) + { + Debug.Assert(retry >= TimeSpan.Zero); + + bufferWriter.WriteUtf8String("retry: "u8); + bufferWriter.WriteUtf8Number((long)retry.TotalMilliseconds); + bufferWriter.WriteUtf8String(_newLine); + } + + bufferWriter.WriteUtf8String(_newLine); + } + + private static void WriteLinesWithPrefix(PooledByteBufferWriter writer, ReadOnlySpan prefix, ReadOnlySpan data) + { + // Writes a potentially multi-line string, prefixing each line with the given prefix. + // Both \n and \r\n sequences are normalized to \n. + + while (true) + { + writer.WriteUtf8String(prefix); + + int i = data.IndexOfAny((byte)'\r', (byte)'\n'); + if (i < 0) + { + writer.WriteUtf8String(data); + return; + } + + int lineLength = i; + if (data[i++] == '\r' && i < data.Length && data[i] == '\n') + { + i++; + } + + ReadOnlySpan nextLine = data.Slice(0, lineLength); + data = data.Slice(i); + + writer.WriteUtf8String(nextLine); + writer.WriteUtf8String(_newLine); + } + } + } +} diff --git a/src/Shared/ServerSentEvents/SseItem.cs b/src/Shared/ServerSentEvents/SseItem.cs new file mode 100644 index 00000000000..9c6092fd3cf --- /dev/null +++ b/src/Shared/ServerSentEvents/SseItem.cs @@ -0,0 +1,81 @@ +// 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 CA1815 // Override equals and operator equals on value types +#pragma warning disable IDE1006 // Naming Styles + +using System.ComponentModel; + +namespace System.Net.ServerSentEvents +{ + /// Represents a server-sent event. + /// Specifies the type of data payload in the event. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal readonly struct SseItem + { + /// The event's type. + [EditorBrowsable(EditorBrowsableState.Never)] + internal readonly string? _eventType; + + /// The event's id. + private readonly string? _eventId; + + /// The event's reconnection interval. + private readonly TimeSpan? _reconnectionInterval; + + /// Initializes a new instance of the struct. + /// The event's payload. + /// The event's type. + /// Thrown when contains a line break. + public SseItem(T data, string? eventType = null) + { + if (eventType.AsSpan().ContainsLineBreaks() is true) + { + ThrowHelper.ThrowArgumentException_CannotContainLineBreaks(nameof(eventType)); + } + + Data = data; + _eventType = eventType; + } + + /// Gets the event's payload. + public T Data { get; } + + /// Gets the event's type. + public string EventType => _eventType ?? SseParser.EventTypeDefault; + + /// Gets the event's id. + /// Thrown when the value contains a line break. + public string? EventId + { + get => _eventId; + init + { + if (value.AsSpan().ContainsLineBreaks() is true) + { + ThrowHelper.ThrowArgumentException_CannotContainLineBreaks(nameof(EventId)); + } + + _eventId = value; + } + } + + /// Gets the event's retry interval. + /// + /// When specified on an event, instructs the client to update its reconnection time to the specified value. + /// + public TimeSpan? ReconnectionInterval + { + get => _reconnectionInterval; + init + { + if (value < TimeSpan.Zero) + { + ThrowHelper.ThrowArgumentException_CannotBeNegative(nameof(ReconnectionInterval)); + } + + _reconnectionInterval = value; + } + } + } +} diff --git a/src/Shared/ServerSentEvents/SseItemParser.cs b/src/Shared/ServerSentEvents/SseItemParser.cs new file mode 100644 index 00000000000..62a2bf475c3 --- /dev/null +++ b/src/Shared/ServerSentEvents/SseItemParser.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Net.ServerSentEvents +{ + /// Encapsulates a method for parsing the bytes payload of a server-sent event. + /// Specifies the type of the return value of the parser. + /// The event's type. + /// The event's payload bytes. + /// The parsed . + internal delegate T SseItemParser(string eventType, ReadOnlySpan data); +} diff --git a/src/Shared/ServerSentEvents/SseParser.cs b/src/Shared/ServerSentEvents/SseParser.cs new file mode 100644 index 00000000000..fbea0995eed --- /dev/null +++ b/src/Shared/ServerSentEvents/SseParser.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.IO; +using System.Text; + +#pragma warning disable S2333 // Redundant modifiers should not be used + +namespace System.Net.ServerSentEvents +{ + /// Provides a parser for parsing server-sent events. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static class SseParser + { + /// The default ("message") for an event that did not explicitly specify a type. + public const string EventTypeDefault = "message"; + + /// Creates a parser for parsing a of server-sent events into a sequence of values. + /// The stream containing the data to parse. + /// + /// The enumerable of strings, which can be enumerated synchronously or asynchronously. The strings + /// are decoded from the UTF8-encoded bytes of the payload of each event. + /// + /// is null. + /// + /// This overload has behavior equivalent to calling with a delegate + /// that decodes the data of each event using 's GetString method. + /// + public static SseParser Create(Stream sseStream) => + Create(sseStream, static (_, bytes) => Helpers.Utf8GetString(bytes)); + + /// Creates a parser for parsing a of server-sent events into a sequence of values. + /// Specifies the type of data in each event. + /// The stream containing the data to parse. + /// The parser to use to transform each payload of bytes into a data element. + /// The enumerable, which can be enumerated synchronously or asynchronously. + /// or is null. + public static SseParser Create(Stream sseStream, SseItemParser itemParser) + { + if (sseStream is null) + { + ThrowHelper.ThrowArgumentNullException(nameof(sseStream)); + } + + if (itemParser is null) + { + ThrowHelper.ThrowArgumentNullException(nameof(itemParser)); + } + + return new SseParser(sseStream, itemParser); + } + } +} diff --git a/src/Shared/ServerSentEvents/SseParser_1.cs b/src/Shared/ServerSentEvents/SseParser_1.cs new file mode 100644 index 00000000000..579f01d2027 --- /dev/null +++ b/src/Shared/ServerSentEvents/SseParser_1.cs @@ -0,0 +1,569 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable SA1649 // File name should match first type name +#pragma warning disable IDE1006 // Naming Styles +#pragma warning disable SA1310 // Field names should not contain underscore +#pragma warning disable SA1203 // Constants should appear before fields +#pragma warning disable SA1514 // Element documentation header should be preceded by blank line +#pragma warning disable SA1623 // Property summary documentation should match accessors +#pragma warning disable IDE0011 // Add braces +#pragma warning disable SA1114 // Parameter list should follow declaration +#pragma warning disable SA1106 // Code should not contain empty statements +#pragma warning disable S109 // Magic numbers should not be used +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable SA1108 // Block statements should not contain embedded comments +#pragma warning disable format + +namespace System.Net.ServerSentEvents +{ + /// Provides a parser for server-sent events information. + /// Specifies the type of data parsed from an event. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal sealed class SseParser + { + // For reference: + // Specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events + + /// Carriage Return. + private const byte CR = (byte)'\r'; + /// Line Feed. + private const byte LF = (byte)'\n'; + /// Carriage Return Line Feed. + private static ReadOnlySpan CRLF => "\r\n"u8; + + /// The maximum number of milliseconds representible by . + private readonly long TimeSpan_MaxValueMilliseconds = (long)TimeSpan.MaxValue.TotalMilliseconds; + + /// The default size of an ArrayPool buffer to rent. + /// Larger size used by default to minimize number of reads. Smaller size used in debug to stress growth/shifting logic. + private const int DefaultArrayPoolRentSize = +#if DEBUG + 16; +#else + 1024; +#endif + + /// The stream to be parsed. + private readonly Stream _stream; + + /// The parser delegate used to transform bytes into a . + private readonly SseItemParser _itemParser; + + /// Indicates whether the enumerable has already been used for enumeration. + private int _used; + + /// Buffer, either empty or rented, containing the data being read from the stream while looking for the next line. + private byte[] _lineBuffer = []; + /// The starting offset of valid data in . + private int _lineOffset; + /// The length of valid data in , starting from . + private int _lineLength; + /// The index in where a newline ('\r', '\n', or "\r\n") was found. + private int _newlineIndex; + /// The index in of characters already checked for newlines. + /// + /// This is to avoid O(LineLength^2) behavior in the rare case where we have long lines that are built-up over multiple reads. + /// We want to avoid re-checking the same characters we've already checked over and over again. + /// + private int _lastSearchedForNewline; + /// Set when eof has been reached in the stream. + private bool _eof; + + /// Rented buffer containing buffered data for the next event. + private byte[]? _dataBuffer; + /// The length of valid data in , starting from index 0. + private int _dataLength; + /// Whether data has been appended to . + /// This can be different than != 0 if empty data was appended. + private bool _dataAppended; + + /// The event type for the next event. + private string? _eventType; + + /// The event id for the next event. + private string? _eventId; + + /// The reconnection interval for the next event. + private TimeSpan? _nextReconnectionInterval; + + /// Initializes a new instance of the class. + /// The stream to parse. + /// The function to use to parse payload bytes into a . + internal SseParser(Stream stream, SseItemParser itemParser) + { + _stream = stream; + _itemParser = itemParser; + } + + /// Gets an enumerable of the server-sent events from this parser. + /// The parser has already been enumerated. Such an exception may propagate out of a call to . + public IEnumerable> Enumerate() + { + // Validate that the parser is only used for one enumeration. + ThrowIfNotFirstEnumeration(); + + // Rent a line buffer. This will grow as needed. The line buffer is what's passed to the stream, + // so we want it to be large enough to reduce the number of reads we need to do when data is + // arriving quickly. (In debug, we use a smaller buffer to stress the growth and shifting logic.) + _lineBuffer = ArrayPool.Shared.Rent(DefaultArrayPoolRentSize); + try + { + // Spec: "Event streams in this format must always be encoded as UTF-8". + // Skip a UTF8 BOM if it exists at the beginning of the stream. (The BOM is defined as optional in the SSE grammar.) + while (FillLineBuffer() != 0 && _lineLength < Utf8Bom.Length); + SkipBomIfPresent(); + + // Process all events in the stream. + while (true) + { + // See if there's a complete line in data already read from the stream. Lines are permitted to + // end with CR, LF, or CRLF. Look for all of them and if we find one, process the line. However, + // if we only find a CR and it's at the end of the read data, don't process it now, as we want + // to process it together with an LF that might immediately follow, rather than treating them + // as two separate characters, in which case we'd incorrectly process the CR as a line by itself. + GetNextSearchOffsetAndLength(out int searchOffset, out int searchLength); + _newlineIndex = _lineBuffer.AsSpan(searchOffset, searchLength).IndexOfAny(CR, LF); + if (_newlineIndex >= 0) + { + _lastSearchedForNewline = -1; + _newlineIndex += searchOffset; + if (_lineBuffer[_newlineIndex] is LF || // the newline is LF + _newlineIndex - _lineOffset + 1 < _lineLength || // we must have CR and we have whatever comes after it + _eof) // if we get here, we know we have a CR at the end of the buffer, so it's definitely the whole newline if we've hit EOF + { + // Process the line. + if (ProcessLine(out SseItem sseItem, out int advance)) + { + yield return sseItem; + } + + // Move past the line. + _lineOffset += advance; + _lineLength -= advance; + continue; + } + } + else + { + // Record the last position searched for a newline. The next time we search, + // we'll search from here rather than from _lineOffset, in order to avoid searching + // the same characters again. + _lastSearchedForNewline = _lineOffset + _lineLength; + } + + // We've processed everything in the buffer we currently can, so if we've already read EOF, we're done. + if (_eof) + { + // Spec: "Once the end of the file is reached, any pending data must be discarded. (If the file ends in the middle of an + // event, before the final empty line, the incomplete event is not dispatched.)" + break; + } + + // Read more data into the buffer. + _ = FillLineBuffer(); + } + } + finally + { + ArrayPool.Shared.Return(_lineBuffer); + if (_dataBuffer is not null) + { + ArrayPool.Shared.Return(_dataBuffer); + } + } + } + + /// Gets an asynchronous enumerable of the server-sent events from this parser. + /// The cancellation token to use to cancel the enumeration. + /// The parser has already been enumerated. May propagate out of a call to . + /// The enumeration was canceled. May propagate out of a call to . + public async IAsyncEnumerable> EnumerateAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Validate that the parser is only used for one enumeration. + ThrowIfNotFirstEnumeration(); + + // Rent a line buffer. This will grow as needed. The line buffer is what's passed to the stream, + // so we want it to be large enough to reduce the number of reads we need to do when data is + // arriving quickly. (In debug, we use a smaller buffer to stress the growth and shifting logic.) + _lineBuffer = ArrayPool.Shared.Rent(DefaultArrayPoolRentSize); + try + { + // Spec: "Event streams in this format must always be encoded as UTF-8". + // Skip a UTF8 BOM if it exists at the beginning of the stream. (The BOM is defined as optional in the SSE grammar.) + while (await FillLineBufferAsync(cancellationToken).ConfigureAwait(false) != 0 && _lineLength < Utf8Bom.Length) ; + SkipBomIfPresent(); + + // Process all events in the stream. + while (true) + { + // See if there's a complete line in data already read from the stream. Lines are permitted to + // end with CR, LF, or CRLF. Look for all of them and if we find one, process the line. However, + // if we only find a CR and it's at the end of the read data, don't process it now, as we want + // to process it together with an LF that might immediately follow, rather than treating them + // as two separate characters, in which case we'd incorrectly process the CR as a line by itself. + GetNextSearchOffsetAndLength(out int searchOffset, out int searchLength); + _newlineIndex = _lineBuffer.AsSpan(searchOffset, searchLength).IndexOfAny(CR, LF); + if (_newlineIndex >= 0) + { + _lastSearchedForNewline = -1; + _newlineIndex += searchOffset; + if (_lineBuffer[_newlineIndex] is LF || // newline is LF + _newlineIndex - _lineOffset + 1 < _lineLength || // newline is CR, and we have whatever comes after it + _eof) // if we get here, we know we have a CR at the end of the buffer, so it's definitely the whole newline if we've hit EOF + { + // Process the line. + if (ProcessLine(out SseItem sseItem, out int advance)) + { + yield return sseItem; + } + + // Move past the line. + _lineOffset += advance; + _lineLength -= advance; + continue; + } + } + else + { + // Record the last position searched for a newline. The next time we search, + // we'll search from here rather than from _lineOffset, in order to avoid searching + // the same characters again. + _lastSearchedForNewline = searchOffset + searchLength; + } + + // We've processed everything in the buffer we currently can, so if we've already read EOF, we're done. + if (_eof) + { + // Spec: "Once the end of the file is reached, any pending data must be discarded. (If the file ends in the middle of an + // event, before the final empty line, the incomplete event is not dispatched.)" + break; + } + + // Read more data into the buffer. + _ = await FillLineBufferAsync(cancellationToken).ConfigureAwait(false); + } + } + finally + { + ArrayPool.Shared.Return(_lineBuffer); + if (_dataBuffer is not null) + { + ArrayPool.Shared.Return(_dataBuffer); + } + } + } + + /// Gets the next index and length with which to perform a newline search. + private void GetNextSearchOffsetAndLength(out int searchOffset, out int searchLength) + { + if (_lastSearchedForNewline > _lineOffset) + { + searchOffset = _lastSearchedForNewline; + searchLength = _lineLength - (_lastSearchedForNewline - _lineOffset); + } + else + { + searchOffset = _lineOffset; + searchLength = _lineLength; + } + + Debug.Assert(searchOffset >= _lineOffset, $"{searchOffset}, {_lineLength}"); + Debug.Assert(searchOffset <= _lineOffset + _lineLength, $"{searchOffset}, {_lineOffset}, {_lineLength}"); + Debug.Assert(searchOffset <= _lineBuffer.Length, $"{searchOffset}, {_lineBuffer.Length}"); + + Debug.Assert(searchLength >= 0, $"{searchLength}"); + Debug.Assert(searchLength <= _lineLength, $"{searchLength}, {_lineLength}"); + } + + private int GetNewLineLength() + { + Debug.Assert(_newlineIndex - _lineOffset < _lineLength, "Expected to be positioned at a non-empty newline"); + return _lineBuffer.AsSpan(_newlineIndex, _lineLength - (_newlineIndex - _lineOffset)).StartsWith(CRLF) ? 2 : 1; + } + + /// + /// If there's no room remaining in the line buffer, either shifts the contents + /// left or grows the buffer in order to make room for the next read. + /// + private void ShiftOrGrowLineBufferIfNecessary() + { + // If data we've read is butting up against the end of the buffer and + // it's not taking up the entire buffer, slide what's there down to + // the beginning, making room to read more data into the buffer (since + // there's no newline in the data that's there). Otherwise, if the whole + // buffer is full, grow the buffer to accommodate more data, since, again, + // what's there doesn't contain a newline and thus a line is longer than + // the current buffer accommodates. + if (_lineOffset + _lineLength == _lineBuffer.Length) + { + if (_lineOffset != 0) + { + _lineBuffer.AsSpan(_lineOffset, _lineLength).CopyTo(_lineBuffer); + if (_lastSearchedForNewline >= 0) + { + _lastSearchedForNewline -= _lineOffset; + } + + _lineOffset = 0; + } + else if (_lineLength == _lineBuffer.Length) + { + GrowBuffer(ref _lineBuffer, _lineBuffer.Length * 2); + } + } + } + + /// Processes a complete line from the SSE stream. + /// The parsed item if the method returns true. + /// How many characters to advance in the line buffer. + /// true if an SSE item was successfully parsed; otherwise, false. + private bool ProcessLine(out SseItem sseItem, out int advance) + { + ReadOnlySpan line = _lineBuffer.AsSpan(_lineOffset, _newlineIndex - _lineOffset); + + // Spec: "If the line is empty (a blank line) Dispatch the event" + if (line.IsEmpty) + { + advance = GetNewLineLength(); + + if (_dataAppended) + { + T data = _itemParser(_eventType ?? SseParser.EventTypeDefault, _dataBuffer.AsSpan(0, _dataLength)); + sseItem = new SseItem(data, _eventType) { EventId = _eventId, ReconnectionInterval = _nextReconnectionInterval }; + _eventType = null; + _eventId = null; + _nextReconnectionInterval = null; + _dataLength = 0; + _dataAppended = false; + return true; + } + + sseItem = default; + return false; + } + + // Find the colon separating the field name and value. + int colonPos = line.IndexOf((byte)':'); + ReadOnlySpan fieldName; + ReadOnlySpan fieldValue; + if (colonPos >= 0) + { + // Spec: "Collect the characters on the line before the first U+003A COLON character (:), and let field be that string." + fieldName = line.Slice(0, colonPos); + + // Spec: "Collect the characters on the line after the first U+003A COLON character (:), and let value be that string. + // If value starts with a U+0020 SPACE character, remove it from value." + fieldValue = line.Slice(colonPos + 1); + if (!fieldValue.IsEmpty && fieldValue[0] == (byte)' ') + { + fieldValue = fieldValue.Slice(1); + } + } + else + { + // Spec: "using the whole line as the field name, and the empty string as the field value." + fieldName = line; + fieldValue = []; + } + + if (fieldName.SequenceEqual("data"u8)) + { + // Spec: "Append the field value to the data buffer, then append a single U+000A LINE FEED (LF) character to the data buffer." + // Spec: "If the data buffer's last character is a U+000A LINE FEED (LF) character, then remove the last character from the data buffer." + + // If there's nothing currently in the data buffer and we can easily detect that this line is immediately followed by + // an empty line, we can optimize it to just handle the data directly from the line buffer, rather than first copying + // into the data buffer and dispatching from there. + if (!_dataAppended) + { + int newlineLength = GetNewLineLength(); + ReadOnlySpan remainder = _lineBuffer.AsSpan(_newlineIndex + newlineLength, _lineLength - line.Length - newlineLength); + if (!remainder.IsEmpty && + (remainder[0] is LF || (remainder[0] is CR && remainder.Length > 1))) + { + advance = line.Length + newlineLength + (remainder.StartsWith(CRLF) ? 2 : 1); + T data = _itemParser(_eventType ?? SseParser.EventTypeDefault, fieldValue); + sseItem = new SseItem(data, _eventType) { EventId = _eventId, ReconnectionInterval = _nextReconnectionInterval }; + _eventType = null; + _eventId = null; + _nextReconnectionInterval = null; + return true; + } + } + + // We need to copy the data from the data buffer to the line buffer. Make sure there's enough room. + if (_dataBuffer is null || _dataLength + _lineLength + 1 > _dataBuffer.Length) + { + GrowBuffer(ref _dataBuffer, _dataLength + _lineLength + 1); + } + + // Append a newline if there's already content in the buffer. + // Then copy the field value to the data buffer + if (_dataAppended) + { + _dataBuffer[_dataLength++] = LF; + } + + fieldValue.CopyTo(_dataBuffer.AsSpan(_dataLength)); + _dataLength += fieldValue.Length; + _dataAppended = true; + } + else if (fieldName.SequenceEqual("event"u8)) + { + // Spec: "Set the event type buffer to field value." + _eventType = Helpers.Utf8GetString(fieldValue); + } + else if (fieldName.SequenceEqual("id"u8)) + { + // Spec: "If the field value does not contain U+0000 NULL, then set the last event ID buffer to the field value. Otherwise, ignore the field." + if (fieldValue.IndexOf((byte)'\0') < 0) + { + // Note that fieldValue might be empty, in which case LastEventId will naturally be reset to the empty string. This is per spec. + LastEventId = _eventId = Helpers.Utf8GetString(fieldValue); + } + } + else if (fieldName.SequenceEqual("retry"u8)) + { + // Spec: "If the field value consists of only ASCII digits, then interpret the field value as an integer in base ten, + // and set the event stream's reconnection time to that integer. Otherwise, ignore the field." + if (long.TryParse( +#if NET + fieldValue, +#else + Helpers.Utf8GetString(fieldValue), +#endif + NumberStyles.None, CultureInfo.InvariantCulture, out long milliseconds) && + milliseconds >= 0 && milliseconds <= TimeSpan_MaxValueMilliseconds) + { + // Workaround for TimeSpan.FromMilliseconds not being able to roundtrip TimeSpan.MaxValue + TimeSpan timeSpan = milliseconds == TimeSpan_MaxValueMilliseconds ? TimeSpan.MaxValue : TimeSpan.FromMilliseconds(milliseconds); + _nextReconnectionInterval = ReconnectionInterval = timeSpan; + } + } + else + { + // We'll end up here if the line starts with a colon, producing an empty field name, or if the field name is otherwise unrecognized. + // Spec: "If the line starts with a U+003A COLON character (:) Ignore the line." + // Spec: "Otherwise, The field is ignored" + } + + advance = line.Length + GetNewLineLength(); + sseItem = default; + return false; + } + + /// Gets the last event ID. + /// This value is updated any time a new last event ID is parsed. It is not reset between SSE items. + public string LastEventId { get; private set; } = string.Empty; // Spec: "must be initialized to the empty string" + + /// Gets the reconnection interval. + /// + /// If no retry event was received, this defaults to , and it will only + /// ever be in that situation. If a client wishes to retry, the server-sent + /// events specification states that the interval may then be decided by the client implementation and should be a + /// few seconds. + /// + public TimeSpan ReconnectionInterval { get; private set; } = Timeout.InfiniteTimeSpan; + + /// Transitions the object to a used state, throwing if it's already been used. + private void ThrowIfNotFirstEnumeration() + { + if (Interlocked.Exchange(ref _used, 1) != 0) + { + ThrowHelper.ThrowInvalidOperationException_EnumerateOnlyOnce(); + } + } + + /// Reads data from the stream into the line buffer. + private int FillLineBuffer() + { + ShiftOrGrowLineBufferIfNecessary(); + + int offset = _lineOffset + _lineLength; + int bytesRead = _stream.Read( +#if NET + _lineBuffer.AsSpan(offset)); +#else + _lineBuffer, offset, _lineBuffer.Length - offset); +#endif + + if (bytesRead > 0) + { + _lineLength += bytesRead; + } + else + { + _eof = true; + bytesRead = 0; + } + + return bytesRead; + } + + /// Reads data asynchronously from the stream into the line buffer. + private async ValueTask FillLineBufferAsync(CancellationToken cancellationToken) + { + ShiftOrGrowLineBufferIfNecessary(); + + int offset = _lineOffset + _lineLength; + int bytesRead = await +#if NET + _stream.ReadAsync(_lineBuffer.AsMemory(offset), cancellationToken) +#else + new ValueTask(_stream.ReadAsync(_lineBuffer, offset, _lineBuffer.Length - offset, cancellationToken)) +#endif + .ConfigureAwait(false); + + if (bytesRead > 0) + { + _lineLength += bytesRead; + } + else + { + _eof = true; + bytesRead = 0; + } + + return bytesRead; + } + + /// Gets the UTF8 BOM. + private static ReadOnlySpan Utf8Bom => [0xEF, 0xBB, 0xBF]; + + /// Called at the beginning of processing to skip over an optional UTF8 byte order mark. + private void SkipBomIfPresent() + { + Debug.Assert(_lineOffset == 0, $"Expected _lineOffset == 0, got {_lineOffset}"); + + if (_lineBuffer.AsSpan(0, _lineLength).StartsWith(Utf8Bom)) + { + _lineOffset += 3; + _lineLength -= 3; + } + } + + /// Grows the buffer, returning the existing one to the ArrayPool and renting an ArrayPool replacement. + private static void GrowBuffer([NotNull] ref byte[]? buffer, int minimumLength) + { + byte[]? toReturn = buffer; + buffer = ArrayPool.Shared.Rent(Math.Max(minimumLength, DefaultArrayPoolRentSize)); + if (toReturn is not null) + { + Array.Copy(toReturn, buffer, toReturn.Length); + ArrayPool.Shared.Return(toReturn); + } + } + } +} diff --git a/src/Shared/ServerSentEvents/ThrowHelper.cs b/src/Shared/ServerSentEvents/ThrowHelper.cs new file mode 100644 index 00000000000..1ab8e8c9b21 --- /dev/null +++ b/src/Shared/ServerSentEvents/ThrowHelper.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable LA0001 // Use Microsoft.Shared.Diagnostics.Throws for improved performance + +namespace System.Net.ServerSentEvents +{ + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static class ThrowHelper + { + [DoesNotReturn] + public static void ThrowArgumentNullException(string parameterName) + { + throw new ArgumentNullException(parameterName); + } + + public static void ThrowInvalidOperationException_EnumerateOnlyOnce() + { + throw new InvalidOperationException("The enumerable may be enumerated only once."); + } + + public static void ThrowArgumentException_CannotContainLineBreaks(string parameterName) + { + throw new ArgumentException("The argument cannot contain line breaks.", parameterName); + } + + public static void ThrowArgumentException_CannotBeNegative(string parameterName) + { + throw new ArgumentException("The argument cannot be a negative value.", parameterName); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs new file mode 100644 index 00000000000..87df45b5bf3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISerializationTests.cs @@ -0,0 +1,740 @@ +// 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.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using OpenAI.Chat; +using Xunit; + +#pragma warning disable S103 // Lines should not be too long + +namespace Microsoft.Extensions.AI; + +public static partial class OpenAISerializationTests +{ + [Fact] + public static async Task RequestDeserialization_SimpleMessage() + { + const string RequestJson = """ + {"messages":[{"role":"user","content":"hello"}],"model":"gpt-4o-mini","max_completion_tokens":10,"temperature":0.5} + """; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); + OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); + + Assert.NotNull(request); + Assert.False(request.Stream); + Assert.Equal("gpt-4o-mini", request.ModelId); + + Assert.NotNull(request.Options); + Assert.Equal("gpt-4o-mini", request.Options.ModelId); + Assert.Equal(0.5f, request.Options.Temperature); + Assert.Equal(10, request.Options.MaxOutputTokens); + Assert.Null(request.Options.TopK); + Assert.Null(request.Options.TopP); + Assert.Null(request.Options.StopSequences); + Assert.Null(request.Options.AdditionalProperties); + Assert.Null(request.Options.Tools); + + ChatMessage message = Assert.Single(request.Messages); + Assert.Equal(ChatRole.User, message.Role); + AIContent content = Assert.Single(message.Contents); + TextContent textContent = Assert.IsType(content); + Assert.Equal("hello", textContent.Text); + Assert.Null(textContent.RawRepresentation); + Assert.Null(textContent.AdditionalProperties); + } + + [Fact] + public static async Task RequestDeserialization_SimpleMessage_Stream() + { + const string RequestJson = """ + {"messages":[{"role":"user","content":"hello"}],"model":"gpt-4o-mini","max_completion_tokens":20,"stream":true,"stream_options":{"include_usage":true},"temperature":0.5} + """; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); + OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); + + Assert.NotNull(request); + Assert.True(request.Stream); + Assert.Equal("gpt-4o-mini", request.ModelId); + + Assert.NotNull(request.Options); + Assert.Equal("gpt-4o-mini", request.Options.ModelId); + Assert.Equal(0.5f, request.Options.Temperature); + Assert.Equal(20, request.Options.MaxOutputTokens); + Assert.Null(request.Options.TopK); + Assert.Null(request.Options.TopP); + Assert.Null(request.Options.StopSequences); + Assert.Null(request.Options.AdditionalProperties); + Assert.Null(request.Options.Tools); + + ChatMessage message = Assert.Single(request.Messages); + Assert.Equal(ChatRole.User, message.Role); + AIContent content = Assert.Single(message.Contents); + TextContent textContent = Assert.IsType(content); + Assert.Equal("hello", textContent.Text); + Assert.Null(textContent.RawRepresentation); + Assert.Null(textContent.AdditionalProperties); + } + + [Fact] + public static async Task RequestDeserialization_MultipleMessages() + { + const string RequestJson = """ + { + "messages": [ + { + "role": "system", + "content": "You are a really nice friend." + }, + { + "role": "user", + "content": "hello!" + }, + { + "role": "assistant", + "content": "hi, how are you?" + }, + { + "role": "user", + "content": "i\u0027m good. how are you?" + } + ], + "model": "gpt-4o-mini", + "frequency_penalty": 0.75, + "presence_penalty": 0.5, + "seed":42, + "stop": [ "great" ], + "temperature": 0.25, + "user": "user", + "logprobs": true, + "logit_bias": { "42" : 0 }, + "parallel_tool_calls": true, + "top_logprobs": 42, + "metadata": { "key": "value" }, + "store": true + } + """; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); + OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); + + Assert.NotNull(request); + Assert.False(request.Stream); + Assert.Equal("gpt-4o-mini", request.ModelId); + + Assert.NotNull(request.Options); + Assert.Equal("gpt-4o-mini", request.Options.ModelId); + Assert.Equal(0.25f, request.Options.Temperature); + Assert.Equal(0.75f, request.Options.FrequencyPenalty); + Assert.Equal(0.5f, request.Options.PresencePenalty); + Assert.Equal(42, request.Options.Seed); + Assert.Equal(["great"], request.Options.StopSequences); + Assert.NotNull(request.Options.AdditionalProperties); + Assert.Equal("user", request.Options.AdditionalProperties["EndUserId"]); + Assert.True((bool)request.Options.AdditionalProperties["IncludeLogProbabilities"]!); + Assert.Single((IDictionary)request.Options.AdditionalProperties["LogitBiases"]!); + Assert.True((bool)request.Options.AdditionalProperties["AllowParallelToolCalls"]!); + Assert.Equal(42, request.Options.AdditionalProperties["TopLogProbabilityCount"]!); + Assert.Single((IDictionary)request.Options.AdditionalProperties["Metadata"]!); + Assert.True((bool)request.Options.AdditionalProperties["StoredOutputEnabled"]!); + + Assert.Collection(request.Messages, + msg => + { + Assert.Equal(ChatRole.System, msg.Role); + Assert.Null(msg.RawRepresentation); + Assert.Null(msg.AdditionalProperties); + + TextContent text = Assert.IsType(Assert.Single(msg.Contents)); + Assert.Equal("You are a really nice friend.", text.Text); + Assert.Null(text.AdditionalProperties); + Assert.Null(text.RawRepresentation); + Assert.Null(text.AdditionalProperties); + }, + msg => + { + Assert.Equal(ChatRole.User, msg.Role); + Assert.Null(msg.RawRepresentation); + Assert.Null(msg.AdditionalProperties); + + TextContent text = Assert.IsType(Assert.Single(msg.Contents)); + Assert.Equal("hello!", text.Text); + Assert.Null(text.AdditionalProperties); + Assert.Null(text.RawRepresentation); + Assert.Null(text.AdditionalProperties); + }, + msg => + { + Assert.Equal(ChatRole.Assistant, msg.Role); + Assert.Null(msg.RawRepresentation); + Assert.Null(msg.AdditionalProperties); + + TextContent text = Assert.IsType(Assert.Single(msg.Contents)); + Assert.Equal("hi, how are you?", text.Text); + Assert.Null(text.AdditionalProperties); + Assert.Null(text.RawRepresentation); + Assert.Null(text.AdditionalProperties); + }, + msg => + { + Assert.Equal(ChatRole.User, msg.Role); + Assert.Null(msg.RawRepresentation); + Assert.Null(msg.AdditionalProperties); + + TextContent text = Assert.IsType(Assert.Single(msg.Contents)); + Assert.Equal("i'm good. how are you?", text.Text); + Assert.Null(text.AdditionalProperties); + Assert.Null(text.RawRepresentation); + Assert.Null(text.AdditionalProperties); + }); + } + + [Fact] + public static async Task RequestDeserialization_MultiPartSystemMessage() + { + const string RequestJson = """ + { + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a really nice friend." + }, + { + "type": "text", + "text": "Really nice." + } + ] + }, + { + "role": "user", + "content": "hello!" + } + ], + "model": "gpt-4o-mini" + } + """; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); + OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); + + Assert.NotNull(request); + Assert.False(request.Stream); + Assert.Equal("gpt-4o-mini", request.ModelId); + + Assert.NotNull(request.Options); + Assert.Equal("gpt-4o-mini", request.Options.ModelId); + Assert.Null(request.Options.Temperature); + Assert.Null(request.Options.FrequencyPenalty); + Assert.Null(request.Options.PresencePenalty); + Assert.Null(request.Options.Seed); + Assert.Null(request.Options.StopSequences); + + Assert.Collection(request.Messages, + msg => + { + Assert.Equal(ChatRole.System, msg.Role); + Assert.Null(msg.RawRepresentation); + Assert.Null(msg.AdditionalProperties); + + Assert.Collection(msg.Contents, + content => + { + TextContent text = Assert.IsType(content); + Assert.Equal("You are a really nice friend.", text.Text); + Assert.Null(text.AdditionalProperties); + Assert.Null(text.RawRepresentation); + Assert.Null(text.AdditionalProperties); + }, + content => + { + TextContent text = Assert.IsType(content); + Assert.Equal("Really nice.", text.Text); + Assert.Null(text.AdditionalProperties); + Assert.Null(text.RawRepresentation); + Assert.Null(text.AdditionalProperties); + }); + }, + msg => + { + Assert.Equal(ChatRole.User, msg.Role); + Assert.Null(msg.RawRepresentation); + Assert.Null(msg.AdditionalProperties); + + TextContent text = Assert.IsType(Assert.Single(msg.Contents)); + Assert.Equal("hello!", text.Text); + Assert.Null(text.AdditionalProperties); + Assert.Null(text.RawRepresentation); + Assert.Null(text.AdditionalProperties); + }); + } + + [Fact] + public static async Task RequestDeserialization_ToolCall() + { + const string RequestJson = """ + { + "messages": [ + { + "role": "user", + "content": "How old is Alice?" + } + ], + "model": "gpt-4o-mini", + "tools": [ + { + "type": "function", + "function": { + "description": "Gets the age of the specified person.", + "name": "GetPersonAge", + "strict": true, + "parameters": { + "type": "object", + "required": [ + "personName" + ], + "properties": { + "personName": { + "description": "The person whose age is being requested", + "type": "string" + } + } + } + } + } + ], + "tool_choice": "auto" + } + """; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); + OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); + + Assert.NotNull(request); + Assert.False(request.Stream); + Assert.Equal("gpt-4o-mini", request.ModelId); + + Assert.NotNull(request.Options); + Assert.Equal("gpt-4o-mini", request.Options.ModelId); + Assert.Null(request.Options.Temperature); + Assert.Null(request.Options.FrequencyPenalty); + Assert.Null(request.Options.PresencePenalty); + Assert.Null(request.Options.Seed); + Assert.Null(request.Options.StopSequences); + + Assert.Equal(ChatToolMode.Auto, request.Options.ToolMode); + Assert.NotNull(request.Options.Tools); + + AIFunction function = Assert.IsAssignableFrom(Assert.Single(request.Options.Tools)); + Assert.Equal("Gets the age of the specified person.", function.Metadata.Description); + Assert.Equal("GetPersonAge", function.Metadata.Name); + Assert.Equal("Strict", Assert.Single(function.Metadata.AdditionalProperties).Key); + Assert.Equal("Return parameter", function.Metadata.ReturnParameter.Description); + Assert.Equal("{}", Assert.IsType(function.Metadata.ReturnParameter.Schema).GetRawText()); + + AIFunctionParameterMetadata parameter = Assert.Single(function.Metadata.Parameters); + Assert.Equal("personName", parameter.Name); + Assert.True(parameter.IsRequired); + + JsonObject parameterSchema = Assert.IsType(JsonNode.Parse(Assert.IsType(parameter.Schema).GetRawText())); + Assert.Equal(2, parameterSchema.Count); + Assert.Equal("The person whose age is being requested", (string)parameterSchema["description"]!); + Assert.Equal("string", (string)parameterSchema["type"]!); + + Dictionary functionArgs = new() { ["personName"] = "John" }; + var ex = await Assert.ThrowsAsync(() => function.InvokeAsync(functionArgs)); + Assert.Contains("does not support being invoked.", ex.Message); + } + + [Fact] + public static async Task RequestDeserialization_ToolChatMessage() + { + const string RequestJson = """ + { + "messages": [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "12345", + "type": "function", + "function": { + "name": "SayHello", + "arguments": "null" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "12345", + "content": "42" + } + ], + "model": "gpt-4o-mini" + } + """; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(RequestJson)); + OpenAIChatCompletionRequest request = await OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream); + + Assert.NotNull(request); + Assert.False(request.Stream); + Assert.Equal("gpt-4o-mini", request.ModelId); + + Assert.NotNull(request.Options); + Assert.Equal("gpt-4o-mini", request.Options.ModelId); + Assert.Null(request.Options.Temperature); + Assert.Null(request.Options.FrequencyPenalty); + Assert.Null(request.Options.PresencePenalty); + Assert.Null(request.Options.Seed); + Assert.Null(request.Options.StopSequences); + + Assert.Collection(request.Messages, + msg => + { + Assert.Equal(ChatRole.Assistant, msg.Role); + Assert.Null(msg.RawRepresentation); + Assert.Null(msg.AdditionalProperties); + + FunctionCallContent text = Assert.IsType(Assert.Single(msg.Contents)); + Assert.Equal("12345", text.CallId); + Assert.Null(text.AdditionalProperties); + Assert.IsType(text.RawRepresentation); + Assert.Null(text.AdditionalProperties); + }, + msg => + { + Assert.Equal(ChatRole.Tool, msg.Role); + Assert.Null(msg.RawRepresentation); + Assert.Null(msg.AdditionalProperties); + + FunctionResultContent frc = Assert.IsType(Assert.Single(msg.Contents)); + Assert.Equal("SayHello", frc.Name); + Assert.Equal("12345", frc.CallId); + Assert.Equal(42, Assert.IsType(frc.Result).GetInt32()); + Assert.Null(frc.AdditionalProperties); + Assert.Null(frc.RawRepresentation); + Assert.Null(frc.AdditionalProperties); + }); + } + + [Fact] + public static async Task SerializeCompletion_SingleChoice() + { + ChatMessage message = new() + { + Role = ChatRole.Assistant, + Contents = [ + new TextContent("Hello! How can I assist you today?"), + new FunctionCallContent( + "callId", + "MyCoolFunc", + new Dictionary + { + ["arg1"] = 42, + ["arg2"] = "str", + }) + ] + }; + + ChatCompletion completion = new(message) + { + CompletionId = "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + ModelId = "gpt-4o-mini-2024-07-18", + CreatedAt = DateTimeOffset.FromUnixTimeSeconds(1_727_888_631), + FinishReason = ChatFinishReason.Stop, + Usage = new() + { + InputTokenCount = 8, + OutputTokenCount = 9, + TotalTokenCount = 17, + AdditionalCounts = new() + { + { "InputTokenDetails.AudioTokenCount", 1 }, + { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.AudioTokenCount", 2 }, + { "OutputTokenDetails.ReasoningTokenCount", 90 }, + } + }, + AdditionalProperties = new() + { + [nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)] = "fp_f85bea6784", + } + }; + + using MemoryStream stream = new(); + await OpenAISerializationHelpers.SerializeAsync(stream, completion); + string result = Encoding.UTF8.GetString(stream.ToArray()); + + AssertJsonEqual(""" + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello! How can I assist you today?", + "refusal": null, + "tool_calls": [ + { + "id": "callId", + "type": "function", + "function": { + "name": "MyCoolFunc", + "arguments": "{\r\n \u0022arg1\u0022: 42,\r\n \u0022arg2\u0022: \u0022str\u0022\r\n}" + } + } + ], + "role": "assistant" + }, + "logprobs": { + "content": [], + "refusal": [] + } + } + ], + "created": 1727888631, + "model": "gpt-4o-mini-2024-07-18", + "system_fingerprint": "fp_f85bea6784", + "object": "chat.completion", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 8, + "total_tokens": 17, + "completion_tokens_details": { + "audio_tokens": 2, + "reasoning_tokens": 90 + }, + "prompt_tokens_details": { + "audio_tokens": 1, + "cached_tokens": 13 + } + } + } + """, result); + } + + [Fact] + public static async Task SerializeCompletion_ManyChoices_ThrowsNotSupportedException() + { + ChatMessage message1 = new() + { + Role = ChatRole.Assistant, + Text = "Hello! How can I assist you today?", + }; + + ChatMessage message2 = new() + { + Role = ChatRole.Assistant, + Text = "Hey there! How can I help?", + }; + + ChatCompletion completion = new([message1, message2]); + + using MemoryStream stream = new(); + var ex = await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeAsync(stream, completion)); + Assert.Contains("multiple choices", ex.Message); + } + + [Fact] + public static async Task SerializeStreamingCompletion() + { + static async IAsyncEnumerable CreateStreamingCompletion() + { + for (int i = 0; i < 5; i++) + { + List contents = [new TextContent($"Streaming update {i}")]; + + if (i == 2) + { + FunctionCallContent fcc = new( + "callId", + "MyCoolFunc", + new Dictionary + { + ["arg1"] = 42, + ["arg2"] = "str", + }); + + contents.Add(fcc); + } + + if (i == 4) + { + UsageDetails usageDetails = new() + { + InputTokenCount = 8, + OutputTokenCount = 9, + TotalTokenCount = 17, + AdditionalCounts = new() + { + { "InputTokenDetails.AudioTokenCount", 1 }, + { "InputTokenDetails.CachedTokenCount", 13 }, + { "OutputTokenDetails.AudioTokenCount", 2 }, + { "OutputTokenDetails.ReasoningTokenCount", 90 }, + } + }; + + contents.Add(new UsageContent(usageDetails)); + } + + yield return new StreamingChatCompletionUpdate + { + CompletionId = "chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl", + ModelId = "gpt-4o-mini-2024-07-18", + CreatedAt = DateTimeOffset.FromUnixTimeSeconds(1_727_888_631), + Role = ChatRole.Assistant, + Contents = contents, + FinishReason = i == 4 ? ChatFinishReason.Stop : null, + AdditionalProperties = new() + { + [nameof(OpenAI.Chat.ChatCompletion.SystemFingerprint)] = "fp_f85bea6784", + }, + }; + + await Task.Yield(); + } + } + + using MemoryStream stream = new(); + await OpenAISerializationHelpers.SerializeStreamingAsync(stream, CreateStreamingCompletion()); + string result = Encoding.UTF8.GetString(stream.ToArray()); + + AssertSseEqual(""" + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","choices":[{"delta":{"content":"Streaming update 0","tool_calls":[],"role":"assistant"},"logprobs":{"content":[],"refusal":[]},"finish_reason":"stop","index":0}],"created":1727888631,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","object":"chat.completion.chunk"} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","choices":[{"delta":{"content":"Streaming update 1","tool_calls":[],"role":"assistant"},"logprobs":{"content":[],"refusal":[]},"finish_reason":"stop","index":0}],"created":1727888631,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","object":"chat.completion.chunk"} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","choices":[{"delta":{"content":"Streaming update 2","tool_calls":[{"index":0,"id":"callId","type":"function","function":{"name":"MyCoolFunc","arguments":"{\r\n \u0022arg1\u0022: 42,\r\n \u0022arg2\u0022: \u0022str\u0022\r\n}"}}],"role":"assistant"},"logprobs":{"content":[],"refusal":[]},"finish_reason":"stop","index":0}],"created":1727888631,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","object":"chat.completion.chunk"} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","choices":[{"delta":{"content":"Streaming update 3","tool_calls":[],"role":"assistant"},"logprobs":{"content":[],"refusal":[]},"finish_reason":"stop","index":0}],"created":1727888631,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","object":"chat.completion.chunk"} + + data: {"id":"chatcmpl-ADymNiWWeqCJqHNFXiI1QtRcLuXcl","choices":[{"delta":{"content":"Streaming update 4","tool_calls":[],"role":"assistant"},"logprobs":{"content":[],"refusal":[]},"finish_reason":"stop","index":0}],"created":1727888631,"model":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_f85bea6784","object":"chat.completion.chunk","usage":{"completion_tokens":9,"prompt_tokens":8,"total_tokens":17,"completion_tokens_details":{"audio_tokens":2,"reasoning_tokens":90},"prompt_tokens_details":{"audio_tokens":1,"cached_tokens":13}}} + + data: [DONE] + + + """, result); + } + + [Fact] + public static async Task SerializationHelpers_NullArguments_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync(() => OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(null!)); + + await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeAsync(null!, new(new ChatMessage()))); + await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeAsync(new MemoryStream(), null!)); + + await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeStreamingAsync(null!, GetStreamingChatCompletion())); + await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeStreamingAsync(new MemoryStream(), null!)); + + static async IAsyncEnumerable GetStreamingChatCompletion() + { + yield return new StreamingChatCompletionUpdate(); + await Task.CompletedTask; + } + } + + [Fact] + public static async Task SerializationHelpers_HonorCancellationToken() + { + CancellationToken canceledToken = new(canceled: true); + MemoryStream stream = new("{}"u8.ToArray()); + + await Assert.ThrowsAsync(() => OpenAISerializationHelpers.DeserializeChatCompletionRequestAsync(stream, cancellationToken: canceledToken)); + await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeAsync(stream, new(new ChatMessage()), cancellationToken: canceledToken)); + await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeStreamingAsync(stream, GetStreamingChatCompletion(), cancellationToken: canceledToken)); + + static async IAsyncEnumerable GetStreamingChatCompletion() + { + yield return new StreamingChatCompletionUpdate(); + await Task.CompletedTask; + } + } + + [Fact] + public static async Task SerializationHelpers_HonorJsonSerializerOptions() + { + FunctionCallContent fcc = new( + "callId", + "MyCoolFunc", + new Dictionary + { + ["arg1"] = new SomeFunctionArgument(), + }); + + ChatCompletion completion = new(new ChatMessage + { + Role = ChatRole.Assistant, + Contents = [fcc], + }); + + using MemoryStream stream = new(); + + // Passing a JSO that contains a contract for the function argument results in successful serialization. + await OpenAISerializationHelpers.SerializeAsync(stream, completion, options: JsonContextWithFunctionArgument.Default.Options); + stream.Position = 0; + + await OpenAISerializationHelpers.SerializeStreamingAsync(stream, GetStreamingCompletion(), options: JsonContextWithFunctionArgument.Default.Options); + stream.Position = 0; + + // Passing a JSO without a contract for the function argument result in failed serialization. + await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeAsync(stream, completion, options: JsonContextWithoutFunctionArgument.Default.Options)); + await Assert.ThrowsAsync(() => OpenAISerializationHelpers.SerializeStreamingAsync(stream, GetStreamingCompletion(), options: JsonContextWithoutFunctionArgument.Default.Options)); + + async IAsyncEnumerable GetStreamingCompletion() + { + yield return new StreamingChatCompletionUpdate + { + Contents = [fcc], + }; + await Task.CompletedTask; + } + } + + private class SomeFunctionArgument; + + [JsonSerializable(typeof(SomeFunctionArgument))] + [JsonSerializable(typeof(IDictionary))] + private partial class JsonContextWithFunctionArgument : JsonSerializerContext; + + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(IDictionary))] + private partial class JsonContextWithoutFunctionArgument : JsonSerializerContext; + + private static void AssertJsonEqual(string expected, string actual) + { + JsonNode? expectedNode = JsonNode.Parse(expected); + JsonNode? actualNode = JsonNode.Parse(actual); + + if (!JsonNode.DeepEquals(expectedNode, actualNode)) + { + // JSON documents are not equal, assert on + // normal form strings for better reporting. + expected = expectedNode?.ToJsonString() ?? "null"; + actual = actualNode?.ToJsonString() ?? "null"; + Assert.Equal(expected.NormalizeNewLines(), actual.NormalizeNewLines()); + } + } + + private static void AssertSseEqual(string expected, string actual) + { + Assert.Equal(expected.NormalizeNewLines(), actual.NormalizeNewLines()); + } + + private static string NormalizeNewLines(this string value) => + value.Replace("\r\n", "\n").Replace("\\r\\n", "\\n"); +} From c50b2f1967cfa3474c85113bc9cb69ec7330bf11 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Thu, 12 Dec 2024 09:45:29 +1100 Subject: [PATCH 180/190] Remove obsolete files (#5733) --- .github/workflows/azure-sync-checkdiff.ps1 | 3 - .github/workflows/azure-sync.yml | 70 ---------------------- 2 files changed, 73 deletions(-) delete mode 100644 .github/workflows/azure-sync-checkdiff.ps1 delete mode 100644 .github/workflows/azure-sync.yml diff --git a/.github/workflows/azure-sync-checkdiff.ps1 b/.github/workflows/azure-sync-checkdiff.ps1 deleted file mode 100644 index b6ff80a4084..00000000000 --- a/.github/workflows/azure-sync-checkdiff.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -# Check the code is in sync -$changed = (select-string "nothing to commit" artifacts\status.txt).count -eq 0 -return $changed \ No newline at end of file diff --git a/.github/workflows/azure-sync.yml b/.github/workflows/azure-sync.yml deleted file mode 100644 index a2972488a6c..00000000000 --- a/.github/workflows/azure-sync.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Azure->Dotnet Extensions Code Sync -on: - # Manual run - workflow_dispatch: - -permissions: - contents: write - issues: write - pull-requests: write - -jobs: - compare_repos: - # Comment out this line to test the scripts in a fork - if: github.repository == 'dotnet/extensions' - name: Sync shared code between Azure and DotNet - runs-on: windows-latest - steps: - - name: Checkout dotnet/extensions - uses: actions/checkout@v3 - with: - # Test this script using changes in a fork - repository: 'dotnet/extensions' - path: dotnet-extensions - ref: main - - name: Checkout azure/dotnet-extensions-experimental - uses: actions/checkout@v3 - with: - # Test this script using changes in a fork - repository: 'azure/dotnet-extensions-experimental' - path: azure-extensions - ref: main - token: ${{ secrets.GITHUB_TOKEN }} - - name: Copy - shell: cmd - working-directory: .\azure-extensions\src\Shared\DotNetSync\ - env: - DOTNETEXTENSIONS_REPO: d:\a\extensions\extensions\dotnet-extensions\ - run: CopyToDotNet.cmd - - name: Diff - shell: cmd - working-directory: .\dotnet-extensions\ - run: | - mkdir ..\artifacts - git status > ..\artifacts\status.txt - git diff > ..\artifacts\diff.txt - - uses: actions/upload-artifact@v3 - with: - name: results - path: artifacts - - name: Check - id: check - shell: pwsh - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $sendpr = .\dotnet-extensions\.github\workflows\azure-sync-checkdiff.ps1 - echo "sendpr=$sendpr" >> $env:GITHUB_OUTPUT - - name: Send PR - if: steps.check.outputs.sendpr == 'true' - # https://github.com/marketplace/actions/create-pull-request - uses: dotnet/actions-create-pull-request@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - path: .\dotnet-extensions - commit-message: 'Sync shared code from azure/dotnet-extensions-experimental' - title: 'Sync shared code from azure/dotnet-extensions-experimental' - body: 'This PR was automatically generated to sync shared code changes from azure/dotnet-extensions-experimental. Fixes https://github.com/azure/dotnet-extensions-experimental/issues/1.' - base: main - branch: github-action/sync-azure - branch-suffix: timestamp From 7d9d58969e56b84beb35b05ce29d22b26f8c97ce Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 11 Dec 2024 17:46:09 -0500 Subject: [PATCH 181/190] Update Azure.AI.OpenAI version to 2.1.0 (#5732) Makes it consistent with OpenAI. Test-only change. --- 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 dce0b4a0ba1..8c4c858de83 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -2,7 +2,7 @@ - + From c08790cf79d2d8feaa53751f039f38638741a154 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Wed, 18 Dec 2024 10:31:14 +1100 Subject: [PATCH 182/190] Bump code coverage (#5700) * Bump code coverage * Break builds only if coverage drops for 100% projects --- eng/scripts/ValidateProjectCoverage.ps1 | 52 +++++++++++++++---- .../Microsoft.Extensions.AI.OpenAI.csproj | 2 +- ...Microsoft.Extensions.Caching.Hybrid.csproj | 2 +- ...osoft.Extensions.Diagnostics.Probes.csproj | 2 +- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/eng/scripts/ValidateProjectCoverage.ps1 b/eng/scripts/ValidateProjectCoverage.ps1 index 39971b8863f..6e554c1ab7e 100644 --- a/eng/scripts/ValidateProjectCoverage.ps1 +++ b/eng/scripts/ValidateProjectCoverage.ps1 @@ -59,6 +59,8 @@ $Errors = New-Object System.Collections.ArrayList $Kudos = New-Object System.Collections.ArrayList $ErrorsMarkdown = @(); $KudosMarkdown = @(); +$FatalErrors = 0; +$Warnings = 0; Write-Verbose "Collecting projects from code coverage report..." $CoberturaReport.coverage.packages.package | ForEach-Object { @@ -66,6 +68,7 @@ $CoberturaReport.coverage.packages.package | ForEach-Object { $LineCoverage = [math]::Round([double]$_.'line-rate' * 100, 2) $BranchCoverage = [math]::Round([double]$_.'branch-rate' * 100, 2) $IsFailed = $false + $IsWarning = $false Write-Verbose "Project $Name with line coverage $LineCoverage and branch coverage $BranchCoverage" @@ -80,7 +83,17 @@ $CoberturaReport.coverage.packages.package | ForEach-Object { # Detect the under-coverage if ($MinCodeCoverage -gt $LineCoverage) { - $IsFailed = $true + if ($MinCodeCoverage -eq 100) { + $ansiEscapeCode = "$esc[1m$esc[0;31m"; + $IsFailed = $true + $FatalErrors++; + } + else { + $ansiEscapeCode = "$esc[1m$esc[0;33m"; + $IsWarning = $true; + $Warnings++; + } + $ErrorsMarkdown += "| $Name | Line | **$MinCodeCoverage** | $LineCoverage :small_red_triangle_down: |" [void]$Errors.Add( ( @@ -88,14 +101,24 @@ $CoberturaReport.coverage.packages.package | ForEach-Object { "Project" = $Name.Replace('Microsoft.Extensions.', 'M.E.').Replace('Microsoft.AspNetCore.', 'M.AC.'); "Coverage Type" = "Line"; "Expected" = $MinCodeCoverage; - "Actual" = "$esc[1m$esc[0;31m$($LineCoverage)$esc[0m" + "Actual" = "$($ansiEscapeCode)$($LineCoverage)$esc[0m" } ) ) } if ($MinCodeCoverage -gt $BranchCoverage) { - $IsFailed = $true + if ($MinCodeCoverage -eq 100) { + $ansiEscapeCode = "$esc[1m$esc[0;31m"; + $IsFailed = $true + $FatalErrors++; + } + else { + $ansiEscapeCode = "$esc[1m$esc[0;33m"; + $IsWarning = $true; + $Warnings++; + } + $ErrorsMarkdown += "| $Name | Branch | **$MinCodeCoverage** | $BranchCoverage :small_red_triangle_down: |" [void]$Errors.Add( ( @@ -103,7 +126,7 @@ $CoberturaReport.coverage.packages.package | ForEach-Object { "Project" = $Name.Replace('Microsoft.Extensions.', 'M.E.').Replace('Microsoft.AspNetCore.', 'M.AC.'); "Coverage Type" = "Branch"; "Expected" = $MinCodeCoverage; - "Actual" = "$esc[1m$esc[0;31m$($BranchCoverage)$esc[0m" + "Actual" = "$($ansiEscapeCode)$($BranchCoverage)$esc[0m" } ) ) @@ -125,8 +148,9 @@ $CoberturaReport.coverage.packages.package | ForEach-Object { ) } - if ($IsFailed) { Write-Host "$Name" -NoNewline; Write-Host " ...failed validation" -ForegroundColor Red } - else { Write-Host "$Name" -NoNewline; Write-Host " ...ok" -ForegroundColor Green } + if ($IsWarning) { Write-Host "$Name" -NoNewline; Write-Host " ...missed the mark" -ForegroundColor Yellow } + elseif ($IsFailed) { Write-Host "$Name" -NoNewline; Write-Host " ...failed validation" -ForegroundColor Red } + else { Write-Host "$Name" -NoNewline; Write-Host " ...ok" -ForegroundColor Green } } else { Write-Host "$Name ...skipping" @@ -175,10 +199,20 @@ if (![string]::IsNullOrWhiteSpace($markdown)) { Write-Host $gitHubCommentVar } -if ($Errors.Count -eq 0) +if ($FatalErrors -gt 0) +{ + Write-Host "`r`nBreaking issues detected." + exit -1; +} + +if ($Warnings -gt 0) +{ + Write-Host "`r`nNon-breaking issues detected." +} + +if ($FatalErrors -eq 0) { - Write-Host "`r`nAll good, no issues found." + Write-Host "`r`nAll good, no issues detected." exit 0; } -exit -1; 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 d3e969337e6..a6d3b013c0d 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 @@ -9,7 +9,7 @@ preview true - 72 + 77 0 diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj index 05638bcea77..ede3b88ca36 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj @@ -27,7 +27,7 @@ dev true EXTEXP0018 - 75 + 86 50 diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/Microsoft.Extensions.Diagnostics.Probes.csproj b/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/Microsoft.Extensions.Diagnostics.Probes.csproj index b83ecbbd0fc..4336188ced0 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/Microsoft.Extensions.Diagnostics.Probes.csproj +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/Microsoft.Extensions.Diagnostics.Probes.csproj @@ -13,7 +13,7 @@ - 70 + 76 75 From a0cc1bbfca38c18e8b89ebf60c2723959c9943bb Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Fri, 20 Dec 2024 12:42:08 +1100 Subject: [PATCH 183/190] Update to public versions (#5749) * Update to public versions * Update NuGet.config --- NuGet.config | 31 +------------------------------ eng/Version.Details.xml | 18 +++++++++--------- eng/Versions.props | 18 +++++++++--------- 3 files changed, 19 insertions(+), 48 deletions(-) diff --git a/NuGet.config b/NuGet.config index b10b99e2a98..e4cbab592d6 100644 --- a/NuGet.config +++ b/NuGet.config @@ -2,13 +2,6 @@ - - - - - - - @@ -20,9 +13,6 @@ - - - @@ -32,34 +22,15 @@ - - - - - - - - - - - - - - - - - - - - + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 024e2e3d7f3..ff004bc1432 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -148,39 +148,39 @@ https://dev.azure.com/dnceng/internal/_git/dotnet-runtime 9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3 - + https://github.com/dotnet/aspnetcore 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef - + https://github.com/dotnet/aspnetcore 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef - + https://github.com/dotnet/aspnetcore 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef - + https://github.com/dotnet/aspnetcore 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef - + https://github.com/dotnet/aspnetcore 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef - + https://github.com/dotnet/aspnetcore 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef - + https://github.com/dotnet/aspnetcore 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef - + https://github.com/dotnet/aspnetcore 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef - + https://github.com/dotnet/aspnetcore 401ae7cb55f1460e038f7f8be0e8c782bfeec1ef diff --git a/eng/Versions.props b/eng/Versions.props index bbf47bbf857..3f1f5688dd0 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -65,15 +65,15 @@ 9.0.0 9.0.0 - 9.0.1 - 9.0.1 - 9.0.1 - 9.0.1 - 9.0.1 - 9.0.1 - 9.0.1 - 9.0.1 - 9.0.1 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 + 9.0.0 From 20c12ef61fc33865f36c1f4f6e8e2240e8c25f32 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 20 Dec 2024 10:55:22 +0000 Subject: [PATCH 184/190] HybridCache (tests only): add explicit System.Runtime.Caching dep to override a transitive dependency (#5755) --- eng/packages/General.props | 1 + .../Microsoft.Extensions.Caching.Hybrid.Tests.csproj | 1 + 2 files changed, 2 insertions(+) diff --git a/eng/packages/General.props b/eng/packages/General.props index 10542a2561a..78a6353abc3 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -23,6 +23,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj index fb8863cf776..b32d9224462 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -21,6 +21,7 @@ + From 82d4bac6df972ef54701229f9fdb82ee52bbb272 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 6 Dec 2024 14:25:00 +0000 Subject: [PATCH 185/190] L1 (only) tag-based invalidation (and cleanup new build warnings) --- .gitattributes | 2 + eng/MSBuild/LegacySupport.props | 2 +- .../Internal/BufferChunk.cs | 2 + .../Internal/DefaultHybridCache.CacheItem.cs | 18 +- .../Internal/DefaultHybridCache.Debug.cs | 6 +- .../DefaultHybridCache.ImmutableCacheItem.cs | 7 +- .../DefaultHybridCache.MutableCacheItem.cs | 5 + .../Internal/DefaultHybridCache.Stampede.cs | 10 +- .../DefaultHybridCache.StampedeStateT.cs | 9 +- .../DefaultHybridCache.TagInvalidation.cs | 91 +++++++++ .../Internal/DefaultHybridCache.cs | 38 +++- .../Internal/TagSet.cs | 189 ++++++++++++++++++ ...Microsoft.Extensions.Caching.Hybrid.csproj | 2 + .../DistributedCacheTests.cs | 4 +- .../LocalInvalidationTests.cs | 73 +++++++ .../TagSetTests.cs | 180 +++++++++++++++++ 16 files changed, 614 insertions(+), 24 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs create mode 100644 src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/TagSet.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs diff --git a/.gitattributes b/.gitattributes index 8e443edcd01..b7f07e1f3ce 100644 --- a/.gitattributes +++ b/.gitattributes @@ -58,3 +58,5 @@ *.dbproj text=auto *.sln text=auto +# Interpret dictionary files as text +*.dic diff \ No newline at end of file diff --git a/eng/MSBuild/LegacySupport.props b/eng/MSBuild/LegacySupport.props index 842951ab867..2983903a196 100644 --- a/eng/MSBuild/LegacySupport.props +++ b/eng/MSBuild/LegacySupport.props @@ -23,7 +23,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/BufferChunk.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/BufferChunk.cs index 0d7d54cfdd6..c4a7a4327cb 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/BufferChunk.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/BufferChunk.cs @@ -80,6 +80,8 @@ internal void RecycleIfAppropriate() Debug.Assert(Array is null && !ReturnToPool, "expected clean slate after recycle"); } + internal ReadOnlySpan AsSpan() => Length == 0 ? default : new(Array!, 0, Length); + // get the data as a ROS; for note on null-logic of Array!, see comment in ToArray internal ReadOnlySequence AsSequence() => Length == 0 ? default : new ReadOnlySequence(Array!, 0, Length); diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs index 05edc65dc06..9ec7a9085fa 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs @@ -13,10 +13,20 @@ internal partial class DefaultHybridCache { internal abstract class CacheItem { + protected CacheItem(long creationTimestamp, TagSet tags) + { + Tags = tags; + CreationTimestamp = creationTimestamp; + } + private int _refCount = 1; // the number of pending operations against this cache item public abstract bool DebugIsImmutable { get; } + public long CreationTimestamp { get; } + + public TagSet Tags { get; } + // Note: the ref count is the number of callers anticipating this value at any given time. Initially, // it is one for a simple "get the value" flow, but if another call joins with us, it'll be incremented. // If either cancels, it will get decremented, with the entire flow being cancelled if it ever becomes @@ -88,6 +98,11 @@ protected virtual void OnFinalRelease() // any required release semantics internal abstract class CacheItem : CacheItem { + protected CacheItem(long creationTimestamp, TagSet tags) + : base(creationTimestamp, tags) + { + } + public abstract bool TryGetSize(out long size); // Attempt to get a value that was *not* previously reserved. @@ -112,6 +127,7 @@ public T GetReservedValue(ILogger log) static void Throw() => throw new ObjectDisposedException("The cache item has been recycled before the value was obtained"); } - internal static CacheItem Create() => ImmutableTypeCache.IsImmutable ? new ImmutableCacheItem() : new MutableCacheItem(); + internal static CacheItem Create(long creationTimestamp, TagSet tags) => ImmutableTypeCache.IsImmutable + ? new ImmutableCacheItem(creationTimestamp, tags) : new MutableCacheItem(creationTimestamp, tags); } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Debug.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Debug.cs index a9901103555..e5125fb8acf 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Debug.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Debug.cs @@ -54,7 +54,6 @@ private partial class MutableCacheItem #endif [Conditional("DEBUG")] - [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Instance state used in debug")] internal void DebugOnlyTrackBuffer(DefaultHybridCache cache) { #if DEBUG @@ -63,11 +62,12 @@ internal void DebugOnlyTrackBuffer(DefaultHybridCache cache) { _cache?.DebugOnlyIncrementOutstandingBuffers(); } +#else + _ = this; // dummy just to prevent CA1822, never hit #endif } [Conditional("DEBUG")] - [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Instance state used in debug")] private void DebugOnlyDecrementOutstandingBuffers() { #if DEBUG @@ -75,6 +75,8 @@ private void DebugOnlyDecrementOutstandingBuffers() { _cache?.DebugOnlyDecrementOutstandingBuffers(); } +#else + _ = this; // dummy just to prevent CA1822, never hit #endif } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs index 2e803d87ad6..fa996ee41bc 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs @@ -12,6 +12,11 @@ internal partial class DefaultHybridCache { private static ImmutableCacheItem? _sharedDefault; + public ImmutableCacheItem(long creationTimestamp, TagSet tags) + : base(creationTimestamp, tags) + { + } + private T _value = default!; // deferred until SetValue public long Size { get; private set; } = -1; @@ -25,7 +30,7 @@ public static ImmutableCacheItem GetReservedShared() ImmutableCacheItem? obj = Volatile.Read(ref _sharedDefault); if (obj is null || !obj.TryReserve()) { - obj = new(); + obj = new(0, TagSet.Empty); // timestamp doesn't matter - not used in L1/L2 _ = obj.TryReserve(); // this is reliable on a new instance Volatile.Write(ref _sharedDefault, obj); } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs index db95e8c4590..e19279656c7 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs @@ -14,6 +14,11 @@ private sealed partial class MutableCacheItem : CacheItem // used to hold private BufferChunk _buffer; private T? _fallbackValue; // only used in the case of serialization failures + public MutableCacheItem(long creationTimestamp, TagSet tags) + : base(creationTimestamp, tags) + { + } + public override bool NeedsEvictionCallback => _buffer.ReturnToPool; public override bool DebugIsImmutable => false; diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Stampede.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Stampede.cs index ef5c570c670..660233e41ef 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Stampede.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Stampede.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -13,7 +14,7 @@ internal partial class DefaultHybridCache private readonly ConcurrentDictionary _currentOperations = new(); // returns true for a new session (in which case: we need to start the work), false for a pre-existing session - public bool GetOrCreateStampedeState(string key, HybridCacheEntryFlags flags, out StampedeState stampedeState, bool canBeCanceled) + public bool GetOrCreateStampedeState(string key, HybridCacheEntryFlags flags, out StampedeState stampedeState, bool canBeCanceled, IEnumerable? tags) { var stampedeKey = new StampedeKey(key, flags); @@ -27,7 +28,7 @@ public bool GetOrCreateStampedeState(string key, HybridCacheEntryFlag // Most common scenario here, then, is that we're not fighting with anyone else // go ahead and create a placeholder state object and *try* to add it. - stampedeState = new StampedeState(this, stampedeKey, canBeCanceled); + stampedeState = new StampedeState(this, stampedeKey, TagSet.Create(tags), canBeCanceled); if (_currentOperations.TryAdd(stampedeKey, stampedeState)) { // successfully added; indeed, no-one else was fighting: we're done @@ -56,8 +57,9 @@ public bool GetOrCreateStampedeState(string key, HybridCacheEntryFlag // Check whether the value was L1-cached by an outgoing operation (for *us* to check needs local-cache-read, // and for *them* to have updated needs local-cache-write, but since the shared us/them key includes flags, // we can skip this if *either* flag is set). - if ((flags & HybridCacheEntryFlags.DisableLocalCache) == 0 && _localCache.TryGetValue(key, out var untyped) - && untyped is CacheItem typed && typed.TryReserve()) + if ((flags & HybridCacheEntryFlags.DisableLocalCache) == 0 + && TryGetExisting(key, out var typed) + && typed.TryReserve()) { stampedeState.SetResultDirect(typed); return false; // the work has ALREADY been done diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs index 77322eecee6..a0ac4a2eddd 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs @@ -28,14 +28,14 @@ internal sealed class StampedeState : StampedeState internal void SetResultDirect(CacheItem value) => _result?.TrySetResult(value); - public StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCanceled) - : base(cache, key, CacheItem.Create(), canBeCanceled) + public StampedeState(DefaultHybridCache cache, in StampedeKey key, TagSet tags, bool canBeCanceled) + : base(cache, key, CacheItem.Create(cache.CurrentTimestamp(), tags), canBeCanceled) { _result = new(TaskCreationOptions.RunContinuationsAsynchronously); } - public StampedeState(DefaultHybridCache cache, in StampedeKey key, CancellationToken token) - : base(cache, key, CacheItem.Create(), token) + public StampedeState(DefaultHybridCache cache, in StampedeKey key, TagSet tags, CancellationToken token) + : base(cache, key, CacheItem.Create(cache.CurrentTimestamp(), tags), token) { // no TCS in this case - this is for SetValue only } @@ -274,7 +274,6 @@ private async Task BackgroundFetchAsync() // ^^^ The first thing we need to do is make sure we're not getting into a thread race over buffer disposal. // In particular, if this cache item is somehow so short-lived that the buffers would be released *before* we're // done writing them to L2, which happens *after* we've provided the value to consumers. - BufferChunk bufferToRelease = default; if (Cache.TrySerialize(newValue, out var buffer, out var serializer)) { diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs new file mode 100644 index 00000000000..87e50abf5d1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs @@ -0,0 +1,91 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +internal partial class DefaultHybridCache +{ + private readonly ConcurrentDictionary _tagInvalidationTimes = []; + + private long _globalInvalidateTimestamp; + + public override ValueTask RemoveByTagAsync(string tag, CancellationToken token = default) + { + InvalidateTagCore(tag); + return default; + } + + public bool IsValid(CacheItem cacheItem) + { + long globalInvalidationTimestamp; + if (IntPtr.Size < sizeof(long)) + { + // prevent torn values on x86 + globalInvalidationTimestamp = Interlocked.Read(ref _globalInvalidateTimestamp); + } + else + { + globalInvalidationTimestamp = _globalInvalidateTimestamp; + } + + var timestamp = cacheItem.CreationTimestamp; + if (timestamp <= globalInvalidationTimestamp) + { + return false; // invalidated by wildcard + } + + var tags = cacheItem.Tags; + switch (tags.Count) + { + case 0: + return true; + case 1: + return !(_tagInvalidationTimes.TryGetValue(tags.GetSinglePrechecked(), out var tagInvalidatedTimestamp) && timestamp <= tagInvalidatedTimestamp); + default: + foreach (var tag in tags.GetSpanPrechecked()) + { + if (_tagInvalidationTimes.TryGetValue(tag, out tagInvalidatedTimestamp) && timestamp <= tagInvalidatedTimestamp) + { + return false; + } + } + + return true; + } + } + + internal long CurrentTimestamp() => _clock.GetUtcNow().UtcTicks; + + private void InvalidateTagCore(string tag) + { + if (string.IsNullOrEmpty(tag)) + { + // nothing sensible to do + return; + } + + var now = CurrentTimestamp(); + if (tag == TagSet.WildcardTag) + { + // on modern runtimes JIT will do a good job of dead-branch removal for this + if (IntPtr.Size < sizeof(long)) + { + // prevent torn values on x86 + _ = Interlocked.Exchange(ref _globalInvalidateTimestamp, now); + } + else + { + _globalInvalidateTimestamp = now; + } + } + else + { + _tagInvalidationTimes[tag] = now; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs index 71dbf71fd54..6c0651155a5 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; @@ -20,6 +21,7 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; /// /// The inbuilt implementation of , as registered via . /// +[SkipLocalsInit] internal sealed partial class DefaultHybridCache : HybridCache { // reserve non-printable characters from keys, to prevent potential L2 abuse @@ -35,6 +37,7 @@ internal sealed partial class DefaultHybridCache : HybridCache private readonly HybridCacheOptions _options; private readonly ILogger _logger; private readonly CacheFeatures _features; // used to avoid constant type-testing + private readonly TimeProvider _clock; private readonly HybridCacheEntryFlags _hardFlags; // *always* present (for example, because no L2) private readonly HybridCacheEntryFlags _defaultFlags; // note this already includes hardFlags @@ -66,7 +69,7 @@ public DefaultHybridCache(IOptions options, IServiceProvider _localCache = services.GetRequiredService(); _options = options.Value; _logger = services.GetService()?.CreateLogger(typeof(HybridCache)) ?? NullLogger.Instance; - + _clock = services.GetService() ?? TimeProvider.System; _backendCache = services.GetService(); // note optional // ignore L2 if it is really just the same L1, wrapped @@ -131,10 +134,11 @@ public override ValueTask GetOrCreateAsync(string key, TState stat } bool eventSourceEnabled = HybridCacheEventSource.Log.IsEnabled(); + if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0) { - if (_localCache.TryGetValue(key, out var untyped) - && untyped is CacheItem typed && typed.TryGetValue(_logger, out var value)) + if (TryGetExisting(key, out var typed) + && typed.TryGetValue(_logger, out var value)) { // short-circuit if (eventSourceEnabled) @@ -153,7 +157,7 @@ public override ValueTask GetOrCreateAsync(string key, TState stat } } - if (GetOrCreateStampedeState(key, flags, out var stampede, canBeCanceled)) + if (GetOrCreateStampedeState(key, flags, out var stampede, canBeCanceled, tags)) { // new query; we're responsible for making it happen if (canBeCanceled) @@ -187,15 +191,12 @@ public override ValueTask RemoveAsync(string key, CancellationToken token = defa return _backendCache is null ? default : new(_backendCache.RemoveAsync(key, token)); } - public override ValueTask RemoveByTagAsync(string tag, CancellationToken token = default) - => default; // tags not yet implemented - public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IEnumerable? tags = null, CancellationToken token = default) { // since we're forcing a write: disable L1+L2 read; we'll use a direct pass-thru of the value as the callback, to reuse all the code // note also that stampede token is not shared with anyone else var flags = GetEffectiveFlags(options) | (HybridCacheEntryFlags.DisableLocalCacheRead | HybridCacheEntryFlags.DisableDistributedCacheRead); - var state = new StampedeState(this, new StampedeKey(key, flags), token); + var state = new StampedeState(this, new StampedeKey(key, flags), TagSet.Create(tags), token); return new(state.ExecuteDirectAsync(value, static (state, _) => new(state), options)); // note this spans L2 write etc } @@ -234,4 +235,25 @@ private bool ValidateKey(string key) // nothing to complain about return true; } + + private bool TryGetExisting(string key, [NotNullWhen(true)] out CacheItem? value) + { + if (_localCache.TryGetValue(key, out var untyped) && untyped is CacheItem typed) + { + // check tag-based and global invalidation + if (IsValid(typed)) + { + value = typed; + return true; + } + + // remove from L1; note there's a little unavoidable race here; worst case is that + // a fresher value gets dropped - we'll have to accept it + _localCache.Remove(key); + } + + // failure + value = null; + return false; + } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/TagSet.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/TagSet.cs new file mode 100644 index 00000000000..62df3b4ccaa --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/TagSet.cs @@ -0,0 +1,189 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +/// +/// Represents zero (null), one (string) or more (string[]) tags, avoiding the additional array overhead when necessary. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1066:Implement IEquatable when overriding Object.Equals", Justification = "Equals throws by intent")] +internal readonly struct TagSet +{ + public static readonly TagSet Empty = default!; + + private readonly object? _tagOrTags; + + private TagSet(string tag) + { + Validate(tag); + _tagOrTags = tag; + } + + private TagSet(string[] tags) + { + Debug.Assert(tags is { Length: > 1 }, "should be non-trivial array"); + foreach (var tag in tags) + { + Validate(tag); + } + + _tagOrTags = tags; + } + + public string GetSinglePrechecked() => (string)_tagOrTags!; // we expect this to fail if used on incorrect types + public Span GetSpanPrechecked() => (string[])_tagOrTags!; // we expect this to fail if used on incorrect types + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Intentional; should not be used")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Blocker Code Smell", "S3877:Exceptions should not be thrown from unexpected methods", Justification = "Intentional; should not be used")] + public override bool Equals(object? obj) => throw new NotSupportedException(); + + // [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Intentional; should not be used")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Blocker Code Smell", "S3877:Exceptions should not be thrown from unexpected methods", Justification = "Intentional; should not be used")] + public override int GetHashCode() => throw new NotSupportedException(); + + public override string ToString() => _tagOrTags switch + { + string tag => tag, + string[] tags => string.Join(", ", tags), + _ => "(no tags)", + }; + + public bool IsEmpty => _tagOrTags is null; + + public int Count => _tagOrTags switch + { + null => 0, + string => 1, + string[] arr => arr.Length, + _ => 0, // should never happen, but treat as empty + }; + + internal bool IsArray => _tagOrTags is string[]; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2201:Do not raise reserved exception types", Justification = "This is the most appropriate exception here.")] + public string this[int index] => _tagOrTags switch + { + string tag when index == 0 => tag, + string[] tags => tags[index], + _ => throw new IndexOutOfRangeException(nameof(index)), + }; + + public void CopyTo(Span target) + { + switch (_tagOrTags) + { + case string tag: + target[0] = tag; + break; + case string[] tags: + tags.CopyTo(target); + break; + } + } + + internal static TagSet Create(IEnumerable? tags) + { + if (tags is null) + { + return Empty; + } + + // note that in multi-tag scenarios we always create a defensive copy + if (tags is ICollection collection) + { + switch (collection.Count) + { + case 0: + return Empty; + case 1 when collection is IList list: + return new TagSet(list[0]); + case 1: + // avoid the GetEnumerator() alloc + var arr = ArrayPool.Shared.Rent(1); + collection.CopyTo(arr, 0); + string tag = arr[0]; + ArrayPool.Shared.Return(arr); + return new TagSet(tag); + default: + arr = new string[collection.Count]; + collection.CopyTo(arr, 0); + return new TagSet(arr); + } + } + + // perhaps overkill, but: avoid as much as possible when unrolling + using var iterator = tags.GetEnumerator(); + if (!iterator.MoveNext()) + { + return Empty; + } + + var firstTag = iterator.Current; + if (!iterator.MoveNext()) + { + return new TagSet(firstTag); + } + + string[] oversized = ArrayPool.Shared.Rent(8); + oversized[0] = firstTag; + int count = 1; + do + { + if (count == oversized.Length) + { + // grow + var bigger = ArrayPool.Shared.Rent(count * 2); + oversized.CopyTo(bigger, 0); + ArrayPool.Shared.Return(oversized); + oversized = bigger; + } + + oversized[count++] = iterator.Current; + } + while (iterator.MoveNext()); + + if (count == oversized.Length) + { + return new TagSet(oversized); + } + else + { + var final = oversized.AsSpan(0, count).ToArray(); + ArrayPool.Shared.Return(oversized); + return new TagSet(final); + } + } + + internal string[] ToArray() // for testing only + { + var arr = new string[Count]; + CopyTo(arr); + return arr; + } + + internal const string WildcardTag = "*"; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S3928:Parameter names used into ArgumentException constructors should match an existing one ", + Justification = "Using parameter name from public callable API")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2208:Instantiate argument exceptions correctly", Justification = "Using parameter name from public callable API")] + private static void Validate(string tag) + { + if (string.IsNullOrWhiteSpace(tag)) + { + ThrowEmpty(); + } + + if (tag == WildcardTag) + { + ThrowReserved(); + } + + static void ThrowEmpty() => throw new ArgumentException("Tags cannot be empty.", "tags"); + static void ThrowReserved() => throw new ArgumentException($"The tag '{WildcardTag}' is reserved and cannot be used in this context.", "tags"); + } +} diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj index ede3b88ca36..b8aff39eb98 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj @@ -29,6 +29,8 @@ EXTEXP0018 86 50 + Fundamentals + true diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs index 5a565866f63..4f3766990cc 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs @@ -185,7 +185,7 @@ public async Task ReadOnlySequenceBufferRoundtrip(int size, SequenceKind kind) Assert.Equal(size, expected.Length); cache.Set(key, payload, _fiveMinutes); - RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(int.MaxValue); + var writer = RecyclableArrayBufferWriter.Create(int.MaxValue); Assert.True(cache.TryGet(key, writer)); Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span)); writer.ResetInPlace(); @@ -247,7 +247,7 @@ public async Task ReadOnlySequenceBufferRoundtripAsync(int size, SequenceKind ki Assert.Equal(size, expected.Length); await cache.SetAsync(key, payload, _fiveMinutes); - RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(int.MaxValue); + var writer = RecyclableArrayBufferWriter.Create(int.MaxValue); Assert.True(await cache.TryGetAsync(key, writer)); Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span)); writer.ResetInPlace(); diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs new file mode 100644 index 00000000000..41f8da5bc9d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class LocalInvalidationTests +{ + private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) + { + var services = new ServiceCollection(); + config?.Invoke(services); + services.AddHybridCache(); + ServiceProvider provider = services.BuildServiceProvider(); + cache = Assert.IsType(provider.GetRequiredService()); + return provider; + } + + [Fact] + public async Task GlobalInvalidateNoTags() + { + using var services = GetDefaultCache(out var cache); + var value = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid())); + + // should work immediately as-is + Assert.Equal(value, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()))); + + // invalidating a normal tag should have no effect + await cache.RemoveByTagAsync("foo"); + Assert.Equal(value, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()))); + + // invalidating everything should force a re-fetch + await cache.RemoveByTagAsync("*"); + var newValue = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid())); + Assert.NotEqual(value, newValue); + + // which should now be repeatable again + Assert.Equal(newValue, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()))); + } + + [Fact] + public async Task TagBasedInvalidate() + { + using var services = GetDefaultCache(out var cache); + string[] tags = ["abc"]; + var value = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags); + + // should work immediately as-is + Assert.Equal(value, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); + + // invalidating a normal tag should have no effect + await cache.RemoveByTagAsync("foo"); + Assert.Equal(value, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); + + // invalidating a tag we have should force a re-fetch + await cache.RemoveByTagAsync("abc"); + var newValue = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags); + Assert.NotEqual(value, newValue); + + // which should now be repeatable again + Assert.Equal(newValue, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); + value = newValue; + + // invalidating everything should force a re-fetch + await cache.RemoveByTagAsync("*"); + newValue = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags); + Assert.NotEqual(value, newValue); + + // which should now be repeatable again + Assert.Equal(newValue, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs new file mode 100644 index 00000000000..1c63ff5e5c2 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs @@ -0,0 +1,180 @@ +// 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.Caching.Hybrid.Internal; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class TagSetTests +{ + [Fact] + public void DefaultEmpty() + { + var tags = TagSet.Empty; + Assert.Equal(0, tags.Count); + Assert.True(tags.IsEmpty); + Assert.False(tags.IsArray); + Assert.Equal("(no tags)", tags.ToString()); + tags.CopyTo(default); + } + + [Fact] + public void EmptyArray() + { + var tags = TagSet.Create([]); + Assert.Equal(0, tags.Count); + Assert.True(tags.IsEmpty); + Assert.False(tags.IsArray); + Assert.Equal("(no tags)", tags.ToString()); + tags.CopyTo(default); + } + + [Fact] + public void EmptyCustom() + { + var tags = TagSet.Create(Custom()); + Assert.Equal(0, tags.Count); + Assert.True(tags.IsEmpty); + Assert.False(tags.IsArray); + Assert.Equal("(no tags)", tags.ToString()); + tags.CopyTo(default); + + static IEnumerable Custom() + { + yield break; + } + } + + [Fact] + public void SingleFromArray() + { + string[] arr = ["abc"]; + var tags = TagSet.Create(arr); + arr.AsSpan().Clear(); // to check defensive copy + Assert.Equal(1, tags.Count); + Assert.False(tags.IsEmpty); + Assert.False(tags.IsArray); + Assert.Equal("abc", tags.ToString()); + var scratch = tags.ToArray(); + Assert.Equal("abc", scratch[0]); + } + + [Fact] + public void SingleFromCustom() + { + var tags = TagSet.Create(Custom()); + Assert.Equal(1, tags.Count); + Assert.False(tags.IsEmpty); + Assert.False(tags.IsArray); + Assert.Equal("abc", tags.ToString()); + var scratch = tags.ToArray(); + Assert.Equal("abc", scratch[0]); + + static IEnumerable Custom() + { + yield return "abc"; + } + } + + [Fact] + public void MultipleFromArray() + { + string[] arr = ["abc", "def", "ghi"]; + var tags = TagSet.Create(arr); + arr.AsSpan().Clear(); // to check defensive copy + Assert.Equal(3, tags.Count); + Assert.False(tags.IsEmpty); + Assert.True(tags.IsArray); + Assert.Equal("abc, def, ghi", tags.ToString()); + var scratch = tags.ToArray(); + Assert.Equal("abc", scratch[0]); + Assert.Equal("def", scratch[1]); + Assert.Equal("ghi", scratch[2]); + } + + [Fact] + public void MultipleFromCustom() + { + var tags = TagSet.Create(Custom()); + Assert.Equal(3, tags.Count); + Assert.False(tags.IsEmpty); + Assert.True(tags.IsArray); + Assert.Equal("abc, def, ghi", tags.ToString()); + var scratch = tags.ToArray(); + Assert.Equal("abc", scratch[0]); + Assert.Equal("def", scratch[1]); + Assert.Equal("ghi", scratch[2]); + + static IEnumerable Custom() + { + yield return "abc"; + yield return "def"; + yield return "ghi"; + } + } + + [Fact] + public void ManyFromArray() + { + string[] arr = LongCustom().ToArray(); + var tags = TagSet.Create(arr); + arr.AsSpan().Clear(); // to check defensive copy + Assert.Equal(128, tags.Count); + Assert.False(tags.IsEmpty); + Assert.True(tags.IsArray); + var scratch = tags.ToArray(); + Assert.Equal(128, scratch.Length); + } + + [Fact] + public void ManyFromCustom() + { + var tags = TagSet.Create(LongCustom()); + Assert.Equal(128, tags.Count); + Assert.False(tags.IsEmpty); + Assert.True(tags.IsArray); + var scratch = tags.ToArray(); + Assert.Equal(128, scratch.Length); + } + + [Fact] + public void InvalidEmpty() + { + var ex = Assert.Throws(() => TagSet.Create(["abc", "", "ghi"])); + Assert.Equal("tags", ex.ParamName); + Assert.StartsWith("Tags cannot be empty.", ex.Message); + } + + [Fact] + public void InvalidReserved() + { + var ex = Assert.Throws(() => TagSet.Create(["abc", "*", "ghi"])); + Assert.Equal("tags", ex.ParamName); + Assert.StartsWith("The tag '*' is reserved and cannot be used in this context.", ex.Message); + } + + private static IEnumerable LongCustom() + { + var rand = new Random(); + for (int i = 0; i < 128; i++) + { + yield return Create(); + } + + string Create() + { + const string Alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + var len = rand.Next(3, 8); +#if NET462 + char[] chars = new char[len]; +#else + Span chars = stackalloc char[len]; +#endif + for (int i = 0; i < chars.Length; i++) + { + chars[i] = Alphabet[rand.Next(0, Alphabet.Length)]; + } + + return new string(chars); + } + } +} From 5c2b67d1fc78a1cb34dc2b0bd78837f0d0ad7e1b Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 13 Dec 2024 17:20:57 +0000 Subject: [PATCH 186/190] L2 tag invalidation - incomplete --- .../Internal/DefaultHybridCache.L2.cs | 88 ++++++++++++- .../DefaultHybridCache.StampedeStateT.cs | 3 + .../DefaultHybridCache.TagInvalidation.cs | 120 +++++++++++++----- .../Internal/DefaultHybridCache.cs | 5 + 4 files changed, 181 insertions(+), 35 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs index 230a657bdc3..a57198f3887 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; +using System.Buffers.Binary; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; @@ -14,6 +16,10 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; internal partial class DefaultHybridCache { + private const int MaxCacheDays = 1000; + private const string TagKeyPrefix = "__MSFT_HCT__"; + private static readonly DistributedCacheEntryOptions _tagInvalidationEntryOptions = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(MaxCacheDays) }; + [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Manual sync check")] [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Manual sync check")] [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Explicit async exception handling")] @@ -73,6 +79,9 @@ static async Task AwaitedBuffersAsync(ValueTask pending, Recy } internal ValueTask SetL2Async(string key, in BufferChunk buffer, HybridCacheEntryOptions? options, CancellationToken token) + => HasBackendCache ? SetDirectL2Async(key, in buffer, GetOptions(options), token) : default; + + internal ValueTask SetDirectL2Async(string key, in BufferChunk buffer, DistributedCacheEntryOptions options, CancellationToken token) { Debug.Assert(buffer.Array is not null, "array should be non-null"); switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) @@ -85,15 +94,90 @@ internal ValueTask SetL2Async(string key, in BufferChunk buffer, HybridCacheEntr arr = buffer.ToArray(); } - return new(_backendCache!.SetAsync(key, arr, GetOptions(options), token)); + return new(_backendCache!.SetAsync(key, arr, options, token)); case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // ReadOnlySequence-based var cache = Unsafe.As(_backendCache!); // type-checked already - return cache.SetAsync(key, buffer.AsSequence(), GetOptions(options), token); + return cache.SetAsync(key, buffer.AsSequence(), options, token); } return default; } + [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Manual async core implementation")] + internal ValueTask InvalidateL2TagAsync(string tag, long timestamp, CancellationToken token) + { + if (!HasBackendCache) + { + return default; // no L2 + } + + byte[] oversized = ArrayPool.Shared.Rent(sizeof(long)); + BinaryPrimitives.WriteInt64LittleEndian(oversized, timestamp); + var pending = SetDirectL2Async(TagKeyPrefix + tag, new BufferChunk(oversized, sizeof(long), false), _tagInvalidationEntryOptions, token); + + if (pending.IsCompletedSuccessfully) + { + pending.GetAwaiter().GetResult(); // ensure observed (IVTS etc) + ArrayPool.Shared.Return(oversized); + return default; + } + else + { + return AwaitedAsync(pending, oversized); + } + + static async ValueTask AwaitedAsync(ValueTask pending, byte[] oversized) + { + await pending.ConfigureAwait(false); + ArrayPool.Shared.Return(oversized); + } + } + + [SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "Cancellation handled internally")] + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "All failure is critical")] + internal async Task SafeReadTagInvalidationAsync(string tag) + { + Debug.Assert(HasBackendCache, "shouldn't be here without L2"); + + const int READ_TIMEOUT = 4000; + + try + { + using var cts = new CancellationTokenSource(millisecondsDelay: READ_TIMEOUT); + var buffer = await GetFromL2Async(TagKeyPrefix + tag, cts.Token).ConfigureAwait(false); + + long timestamp; + if (buffer.Array is not null) + { + if (buffer.Length == sizeof(long)) + { + timestamp = BinaryPrimitives.ReadInt64LittleEndian(buffer.AsSpan()); + } + else + { + // not what we expected! assume invalid + timestamp = CurrentTimestamp(); + } + + buffer.RecycleIfAppropriate(); + } + else + { + timestamp = 0; // never invalidated + } + + buffer.RecycleIfAppropriate(); + return timestamp; + } + catch (Exception ex) // this is the "Safe" in "SafeReadTagInvalidationAsync" + { + Debug.WriteLine(ex.Message); + + // if anything goes wrong reading tag invalidations; we have to assume the tag is invalid + return CurrentTimestamp(); + } + } + internal void SetL1(string key, CacheItem value, HybridCacheEntryOptions? options) { // incr ref-count for the the cache itself; this *may* be released via the NeedsEvictionCallback path diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs index a0ac4a2eddd..ef4cdaaf8ed 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs @@ -169,6 +169,9 @@ private async Task BackgroundFetchAsync() // read from L2 if appropriate if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheRead) == 0) { + // kick off any necessary tag invalidation fetches + Cache.PrefetchTags(CacheItem.Tags); + BufferChunk result; try { diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs index 87e50abf5d1..6c490b11059 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs @@ -10,33 +10,36 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; internal partial class DefaultHybridCache { - private readonly ConcurrentDictionary _tagInvalidationTimes = []; + private static readonly Task _zeroTimestamp = Task.FromResult(0L); - private long _globalInvalidateTimestamp; + private readonly ConcurrentDictionary> _tagInvalidationTimes = []; + + private Task _globalInvalidateTimestamp; public override ValueTask RemoveByTagAsync(string tag, CancellationToken token = default) { - InvalidateTagCore(tag); - return default; + if (string.IsNullOrWhiteSpace(tag)) + { + return default; // nothing sensible to do + } + + var now = CurrentTimestamp(); + InvalidateTagLocalCore(tag, now, isNow: true); // isNow to be 100% explicit + return InvalidateL2TagAsync(tag, now, token); } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S1144:Unused private types or members should be removed", Justification = "Completion-checked")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Completion-checked")] public bool IsValid(CacheItem cacheItem) { - long globalInvalidationTimestamp; - if (IntPtr.Size < sizeof(long)) - { - // prevent torn values on x86 - globalInvalidationTimestamp = Interlocked.Read(ref _globalInvalidateTimestamp); - } - else - { - globalInvalidationTimestamp = _globalInvalidateTimestamp; - } - var timestamp = cacheItem.CreationTimestamp; - if (timestamp <= globalInvalidationTimestamp) + + if (_globalInvalidateTimestamp.IsCompleted) { - return false; // invalidated by wildcard + if (timestamp <= _globalInvalidateTimestamp.Result) + { + return false; // invalidated by wildcard + } } var tags = cacheItem.Tags; @@ -44,48 +47,99 @@ public bool IsValid(CacheItem cacheItem) { case 0: return true; + case 1: - return !(_tagInvalidationTimes.TryGetValue(tags.GetSinglePrechecked(), out var tagInvalidatedTimestamp) && timestamp <= tagInvalidatedTimestamp); + return !IsTagExpired(tags.GetSinglePrechecked(), timestamp); + default: + bool allValid = true; foreach (var tag in tags.GetSpanPrechecked()) { - if (_tagInvalidationTimes.TryGetValue(tag, out tagInvalidatedTimestamp) && timestamp <= tagInvalidatedTimestamp) + if (IsTagExpired(tag, timestamp)) { - return false; + allValid = false; // but check them all, to kick-off tag fetch } } - return true; + return allValid; } } internal long CurrentTimestamp() => _clock.GetUtcNow().UtcTicks; - private void InvalidateTagCore(string tag) + internal void PrefetchTags(TagSet tags) { - if (string.IsNullOrEmpty(tag)) + if (HasBackendCache && !tags.IsEmpty) { - // nothing sensible to do - return; + // only needed if L2 exists + switch (tags.Count) + { + case 1: + PrefetchTagWithBackendCache(tags.GetSinglePrechecked()); + break; + default: + foreach (var tag in tags.GetSpanPrechecked()) + { + PrefetchTagWithBackendCache(tag); + } + + break; + } } + } - var now = CurrentTimestamp(); - if (tag == TagSet.WildcardTag) + private void PrefetchTagWithBackendCache(string tag) + { + if (!_tagInvalidationTimes.TryGetValue(tag, out var pending)) { - // on modern runtimes JIT will do a good job of dead-branch removal for this - if (IntPtr.Size < sizeof(long)) + _ = _tagInvalidationTimes.TryAdd(tag, SafeReadTagInvalidationAsync(tag)); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S1144:Unused private types or members should be removed", Justification = "Completion-checked")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Completion-checked")] + private bool IsTagExpired(string tag, long timestamp) + { + if (!_tagInvalidationTimes.TryGetValue(tag, out var pending)) + { + // not in the tag invalidation cache; if we have L2, need to check there + if (HasBackendCache) { - // prevent torn values on x86 - _ = Interlocked.Exchange(ref _globalInvalidateTimestamp, now); + pending = SafeReadTagInvalidationAsync(tag); + _ = _tagInvalidationTimes.TryAdd(tag, pending); } else { - _globalInvalidateTimestamp = now; + // not invalidated, and no L2 to check + return false; + } + } + + if (pending.IsCompleted) + { + return timestamp > pending.Result; + } + else + { + return true; // assume invalid until completed + } + } + + private void InvalidateTagLocalCore(string tag, long timestamp, bool isNow) + { + var timestampTask = Task.FromResult(timestamp); + if (tag == TagSet.WildcardTag) + { + _globalInvalidateTimestamp = timestampTask; + if (isNow && !HasBackendCache) + { + // no L2, so we don't need any prior invalidated tags any more; can clear + _tagInvalidationTimes.Clear(); } } else { - _tagInvalidationTimes[tag] = now; + _tagInvalidationTimes[tag] = timestampTask; } } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs index 6c0651155a5..3f1ecfad7c0 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs @@ -63,6 +63,8 @@ internal enum CacheFeatures [MethodImpl(MethodImplOptions.AggressiveInlining)] private CacheFeatures GetFeatures(CacheFeatures mask) => _features & mask; + internal bool HasBackendCache => (_features & CacheFeatures.BackendCache) != 0; + public DefaultHybridCache(IOptions options, IServiceProvider services) { _services = Throw.IfNull(services); @@ -110,6 +112,9 @@ public DefaultHybridCache(IOptions options, IServiceProvider _defaultExpiration = defaultEntryOptions?.Expiration ?? TimeSpan.FromMinutes(5); _defaultLocalCacheExpiration = defaultEntryOptions?.LocalCacheExpiration ?? TimeSpan.FromMinutes(1); _defaultDistributedCacheExpiration = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = _defaultExpiration }; + + // do this last + _globalInvalidateTimestamp = _backendCache is null ? _zeroTimestamp : SafeReadTagInvalidationAsync(TagSet.WildcardTag); } internal IDistributedCache? BackendCache => _backendCache; From 2d0a8678261e1fe65d37fe3c7c5d64707dd6bd67 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 16 Dec 2024 15:56:58 +0000 Subject: [PATCH 187/190] fix PEBKAC --- .../DefaultHybridCache.TagInvalidation.cs | 2 +- .../L2Tests.cs | 2 +- .../LocalInvalidationTests.cs | 110 ++++++++++++++---- 3 files changed, 88 insertions(+), 26 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs index 6c490b11059..0a8b2a7731a 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs @@ -117,7 +117,7 @@ private bool IsTagExpired(string tag, long timestamp) if (pending.IsCompleted) { - return timestamp > pending.Result; + return timestamp <= pending.Result; } else { diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs index 850c6a054b9..de93385f738 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs @@ -204,7 +204,7 @@ async ValueTask IBufferDistributedCache.TryGetAsync(string key, IBufferWri } } - private class LoggingCache(ITestOutputHelper log, IDistributedCache tail) : IDistributedCache + internal class LoggingCache(ITestOutputHelper log, IDistributedCache tail) : IDistributedCache { protected ITestOutputHelper Log => log; protected IDistributedCache Tail => tail; diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs index 41f8da5bc9d..f6551cb7904 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs @@ -1,11 +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 System.ComponentModel; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit.Abstractions; +using static Microsoft.Extensions.Caching.Hybrid.Tests.L2Tests; namespace Microsoft.Extensions.Caching.Hybrid.Tests; -public class LocalInvalidationTests +public class LocalInvalidationTests(ITestOutputHelper log) { private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) { @@ -38,36 +44,92 @@ public async Task GlobalInvalidateNoTags() // which should now be repeatable again Assert.Equal(newValue, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()))); } + static class Options + { + public static IOptions Create(T value) + where T : class + => new OptionsImpl(value); - [Fact] - public async Task TagBasedInvalidate() + private sealed class OptionsImpl : IOptions + where T : class + { + public OptionsImpl(T value) + { + Value = value; + } + + public T Value { get; } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TagBasedInvalidate(bool withL2) { - using var services = GetDefaultCache(out var cache); - string[] tags = ["abc"]; - var value = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags); + using IMemoryCache l1 = new MemoryCache(new MemoryCacheOptions()); + IDistributedCache? l2 = null; + if (withL2) + { + MemoryDistributedCacheOptions options = new(); + MemoryDistributedCache mdc = new(Options.Create(options)); + l2 = new LoggingCache(log, mdc); + } - // should work immediately as-is - Assert.Equal(value, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); + Guid lastValue = Guid.Empty; + for (int i = 0; i < 3; i++) // because we want to test pre-existing L1/L2 impact + { + using var services = GetDefaultCache(out var cache, svc => + { + svc.AddSingleton(l1); + if (l2 is not null) + { + svc.AddSingleton(l2); + } + }); + var clock = services.GetRequiredService(); - // invalidating a normal tag should have no effect - await cache.RemoveByTagAsync("foo"); - Assert.Equal(value, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); + string[] tags = ["abc"]; + var value = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags); + log.WriteLine($"First value: {value}"); + if (lastValue != Guid.Empty) + { + Assert.Equal(lastValue, value); + } - // invalidating a tag we have should force a re-fetch - await cache.RemoveByTagAsync("abc"); - var newValue = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags); - Assert.NotEqual(value, newValue); + // should work immediately as-is + Assert.Equal(value, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); - // which should now be repeatable again - Assert.Equal(newValue, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); - value = newValue; + // invalidating a normal tag should have no effect + await cache.RemoveByTagAsync("foo"); + Assert.Equal(value, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); - // invalidating everything should force a re-fetch - await cache.RemoveByTagAsync("*"); - newValue = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags); - Assert.NotEqual(value, newValue); + // invalidating a tag we have should force a re-fetch + await cache.RemoveByTagAsync("abc"); + var newValue = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags); + log.WriteLine($"Value after invalidating tag abc: {value}"); + Assert.NotEqual(value, newValue); - // which should now be repeatable again - Assert.Equal(newValue, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); + // which should now be repeatable again + Assert.Equal(newValue, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); + value = newValue; + + // invalidating everything should force a re-fetch + await cache.RemoveByTagAsync("*"); + newValue = await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags); + log.WriteLine($"Value after invalidating tag *: {value}"); + Assert.NotEqual(value, newValue); + + // which should now be repeatable again + Assert.Equal(newValue, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()), tags: tags)); + lastValue = newValue; + + var now = clock.GetTimestamp(); + do + { + await Task.Delay(10); + } + while (clock.GetTimestamp() == now); + } } } From e5f0ee26384b9d5c734d50b70f4d344307a55483 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 17 Dec 2024 15:31:09 +0000 Subject: [PATCH 188/190] infrastructure for payload read,write,validation --- .../DefaultHybridCache.TagInvalidation.cs | 172 ++++++-- .../Internal/DefaultHybridCache.cs | 5 + .../Internal/DistributedCachePayload.cs | 406 ++++++++++++++++++ .../Internal/TagSet.cs | 31 +- .../DistributedCacheTests.cs | 4 +- .../LocalInvalidationTests.cs | 2 +- .../PayloadTests.cs | 259 +++++++++++ 7 files changed, 844 insertions(+), 35 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DistributedCachePayload.cs create mode 100644 test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs index 0a8b2a7731a..326263efa70 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs @@ -14,6 +14,11 @@ internal partial class DefaultHybridCache private readonly ConcurrentDictionary> _tagInvalidationTimes = []; +#if NET9_0_OR_GREATER + private readonly ConcurrentDictionary>.AlternateLookup> _tagInvalidationTimesBySpan; + private readonly bool _tagInvalidationTimesUseAltLookup; +#endif + private Task _globalInvalidateTimestamp; public override ValueTask RemoveByTagAsync(string tag, CancellationToken token = default) @@ -28,18 +33,13 @@ public override ValueTask RemoveByTagAsync(string tag, CancellationToken token = return InvalidateL2TagAsync(tag, now, token); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S1144:Unused private types or members should be removed", Justification = "Completion-checked")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Completion-checked")] public bool IsValid(CacheItem cacheItem) { var timestamp = cacheItem.CreationTimestamp; - if (_globalInvalidateTimestamp.IsCompleted) + if (IsWildcardExpired(timestamp)) { - if (timestamp <= _globalInvalidateTimestamp.Result) - { - return false; // invalidated by wildcard - } + return false; } var tags = cacheItem.Tags; @@ -49,13 +49,13 @@ public bool IsValid(CacheItem cacheItem) return true; case 1: - return !IsTagExpired(tags.GetSinglePrechecked(), timestamp); + return !IsTagExpired(tags.GetSinglePrechecked(), timestamp, out _); default: bool allValid = true; foreach (var tag in tags.GetSpanPrechecked()) { - if (IsTagExpired(tag, timestamp)) + if (IsTagExpired(tag, timestamp, out _)) { allValid = false; // but check them all, to kick-off tag fetch } @@ -65,41 +65,52 @@ public bool IsValid(CacheItem cacheItem) } } - internal long CurrentTimestamp() => _clock.GetUtcNow().UtcTicks; - - internal void PrefetchTags(TagSet tags) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Completion-checked")] + public bool IsWildcardExpired(long timestamp) { - if (HasBackendCache && !tags.IsEmpty) + if (_globalInvalidateTimestamp.IsCompleted) { - // only needed if L2 exists - switch (tags.Count) + if (timestamp <= _globalInvalidateTimestamp.Result) { - case 1: - PrefetchTagWithBackendCache(tags.GetSinglePrechecked()); - break; - default: - foreach (var tag in tags.GetSpanPrechecked()) - { - PrefetchTagWithBackendCache(tag); - } - - break; + return true; } } + + return false; } - private void PrefetchTagWithBackendCache(string tag) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Completion-checked")] + public bool IsTagExpired(ReadOnlySpan tag, long timestamp, out bool isPending) { - if (!_tagInvalidationTimes.TryGetValue(tag, out var pending)) + isPending = false; +#if NET9_0_OR_GREATER + if (_tagInvalidationTimesUseAltLookup && _tagInvalidationTimesBySpan.TryGetValue(tag, out var pending)) { - _ = _tagInvalidationTimes.TryAdd(tag, SafeReadTagInvalidationAsync(tag)); + if (pending.IsCompleted) + { + return timestamp <= pending.Result; + } + else + { + isPending = true; + return true; // assume invalid until completed + } } + else if (!HasBackendCache) + { + // not invalidated, and no L2 to check + return false; + } +#endif + + // fallback to using a string + return IsTagExpired(tag.ToString(), timestamp, out isPending); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S1144:Unused private types or members should be removed", Justification = "Completion-checked")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Completion-checked")] - private bool IsTagExpired(string tag, long timestamp) + public bool IsTagExpired(string tag, long timestamp, out bool isPending) { + isPending = false; if (!_tagInvalidationTimes.TryGetValue(tag, out var pending)) { // not in the tag invalidation cache; if we have L2, need to check there @@ -121,10 +132,111 @@ private bool IsTagExpired(string tag, long timestamp) } else { + isPending = true; return true; // assume invalid until completed } } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "Ack")] + public ValueTask IsAnyTagExpiredAsync(TagSet tags, long timestamp) + { + return tags.Count switch + { + 0 => new(false), + 1 => IsTagExpiredAsync(tags.GetSinglePrechecked(), timestamp), + _ => SlowAsync(this, tags, timestamp), + }; + + static async ValueTask SlowAsync(DefaultHybridCache @this, TagSet tags, long timestamp) + { + int count = tags.Count; + for (int i = 0; i < count; i++) + { + if (await @this.IsTagExpiredAsync(tags[i], timestamp).ConfigureAwait(false)) + { + return true; + } + } + + return false; + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "Ack")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Completion-checked")] + public ValueTask IsTagExpiredAsync(string tag, long timestamp) + { + if (!_tagInvalidationTimes.TryGetValue(tag, out var pending)) + { + // not in the tag invalidation cache; if we have L2, need to check there + if (HasBackendCache) + { + pending = SafeReadTagInvalidationAsync(tag); + _ = _tagInvalidationTimes.TryAdd(tag, pending); + } + else + { + // not invalidated, and no L2 to check + return new(false); + } + } + + if (pending.IsCompleted) + { + return new(timestamp <= pending.Result); + } + else + { + return AwaitedAsync(pending, timestamp); + } + + static async ValueTask AwaitedAsync(Task pending, long timestamp) => timestamp <= await pending.ConfigureAwait(false); + } + + internal void DebugInvalidateTag(string tag, Task pending) + { + if (tag == TagSet.WildcardTag) + { + _globalInvalidateTimestamp = pending; + } + else + { + _tagInvalidationTimes[tag] = pending; + } + } + + internal long CurrentTimestamp() => _clock.GetUtcNow().UtcTicks; + + internal void PrefetchTags(TagSet tags) + { + if (HasBackendCache && !tags.IsEmpty) + { + // only needed if L2 exists + switch (tags.Count) + { + case 1: + PrefetchTagWithBackendCache(tags.GetSinglePrechecked()); + break; + default: + foreach (var tag in tags.GetSpanPrechecked()) + { + PrefetchTagWithBackendCache(tag); + } + + break; + } + } + } + + private void PrefetchTagWithBackendCache(string tag) + { + if (!_tagInvalidationTimes.TryGetValue(tag, out var pending)) + { + _ = _tagInvalidationTimes.TryAdd(tag, SafeReadTagInvalidationAsync(tag)); + } + } + private void InvalidateTagLocalCore(string tag, long timestamp, bool isNow) { var timestampTask = Task.FromResult(timestamp); diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs index 3f1ecfad7c0..6eb026125a5 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -113,6 +114,10 @@ public DefaultHybridCache(IOptions options, IServiceProvider _defaultLocalCacheExpiration = defaultEntryOptions?.LocalCacheExpiration ?? TimeSpan.FromMinutes(1); _defaultDistributedCacheExpiration = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = _defaultExpiration }; +#if NET9_0_OR_GREATER + _tagInvalidationTimesUseAltLookup = _tagInvalidationTimes.TryGetAlternateLookup(out _tagInvalidationTimesBySpan); +#endif + // do this last _globalInvalidateTimestamp = _backendCache is null ? _zeroTimestamp : SafeReadTagInvalidationAsync(TagSet.WildcardTag); } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DistributedCachePayload.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DistributedCachePayload.cs new file mode 100644 index 00000000000..5bc2979f5cc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DistributedCachePayload.cs @@ -0,0 +1,406 @@ +// 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.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Text; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +// logic related to the payload that we send to IDistributedCache +internal static class DistributedCachePayload +{ + // FORMAT (v1): + // fixed-size header (so that it can be reliably broadcast) + // 2 bytes: sentinel+version + // 2 bytes: entropy (this is a random, and is to help with multi-node collisions at the same time) + // 8 bytes: creation time (UTC ticks, little-endian) + + // and the dynamic part + // varint: flags (little-endian) + // varint: payload size + // varint: duration (ticks relative to creation time) + // varint: tag count + // varint+utf8: key + // (for each tag): varint+utf8: tagN + // (payload-size bytes): payload + // 2 bytes: sentinel+version (repeated, for reliability) + // (at this point, all bytes *must* be exhausted, or it is treated as failure) + + // the encoding for varint etc is akin to BinaryWriter, also comparable to FormatterBinaryWriter in OutputCaching + + private const int MaxVarint64Length = 10; + private const byte SentinelPrefix = 0x03; + private const byte ProtocolVersion = 0x01; + private const ushort UInt16SentinelPrefixPair = (ProtocolVersion << 8) | SentinelPrefix; + + private static readonly Random _entropySource = new(); // doesn't need to be cryptographic + private static readonly UTF8Encoding _utf8NoBom = new(false); + + [Flags] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Minor Code Smell", "S2344:Enumeration type names should not have \"Flags\" or \"Enum\" suffixes", Justification = "Clarity")] + internal enum PayloadFlags : uint + { + None = 0, + } + + internal enum ParseResult + { + Success = 0, + NotRecognized = 1, + InvalidData = 2, + InvalidKey = 3, + ExpiredSelf = 4, + ExpiredTag = 5, + ExpiredWildcard = 6, + } + + public static int GetMaxBytes(string key, TagSet tags, int payloadSize) + { + int length = + 2 // sentinel+version + + 2 // entropy + + 8 // creation time + + MaxVarint64Length // flags + + MaxVarint64Length // payload size + + MaxVarint64Length // duration + + MaxVarint64Length // tag count + + 2 // trailing sentinel + version + + GetMaxStringLength(key.Length) // key + + payloadSize; // the payload itself + + // keys + switch (tags.Count) + { + case 0: + break; + case 1: + length += GetMaxStringLength(tags.GetSinglePrechecked().Length); + break; + default: + foreach (var tag in tags.GetSpanPrechecked()) + { + length += GetMaxStringLength(tag.Length); + } + + break; + } + + return length; + + // pay the cost to get the actual length, to avoid significant + // over-estiamte in ASCII cases + static int GetMaxStringLength(int charCount) => + MaxVarint64Length + _utf8NoBom.GetMaxByteCount(charCount); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Encoding details; clear in context")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5394:Do not use insecure randomness", Justification = "Not cryptographic")] + public static int Write(byte[] destination, + string key, long creationTime, TimeSpan duration, PayloadFlags flags, TagSet tags, ReadOnlySequence payload) + { + var payloadLength = checked((int)payload.Length); + + BinaryPrimitives.WriteUInt16LittleEndian(destination.AsSpan(0, 2), UInt16SentinelPrefixPair); + BinaryPrimitives.WriteUInt16LittleEndian(destination.AsSpan(2, 2), (ushort)_entropySource.Next(0, 0x010000)); // Next is exclusive at RHS + BinaryPrimitives.WriteInt64LittleEndian(destination.AsSpan(4, 8), creationTime); + var len = 12; + + long durationTicks = duration.Ticks; + if (durationTicks < 0) + { + durationTicks = 0; + } + + Write7BitEncodedInt64(destination, ref len, (uint)flags); + Write7BitEncodedInt64(destination, ref len, (ulong)payloadLength); + Write7BitEncodedInt64(destination, ref len, (ulong)durationTicks); + Write7BitEncodedInt64(destination, ref len, (ulong)tags.Count); + WriteString(destination, ref len, key); + switch (tags.Count) + { + case 0: + break; + case 1: + WriteString(destination, ref len, tags.GetSinglePrechecked()); + break; + default: + foreach (var tag in tags.GetSpanPrechecked()) + { + WriteString(destination, ref len, tag); + } + + break; + } + + payload.CopyTo(destination.AsSpan(len, payloadLength)); + len += payloadLength; + BinaryPrimitives.WriteUInt16LittleEndian(destination.AsSpan(len, 2), UInt16SentinelPrefixPair); + return len + 2; + + static void Write7BitEncodedInt64(byte[] target, ref int offset, ulong value) + { + // Write out an int 7 bits at a time. The high bit of the byte, + // when on, tells reader to continue reading more bytes. + // + // Using the constants 0x7F and ~0x7F below offers smaller + // codegen than using the constant 0x80. + + while (value > 0x7Fu) + { + target[offset++] = (byte)((uint)value | ~0x7Fu); + value >>= 7; + } + + target[offset++] = (byte)value; + } + + static void WriteString(byte[] target, ref int offset, string value) + { + var len = _utf8NoBom.GetByteCount(value); + Write7BitEncodedInt64(target, ref offset, (ulong)len); + offset += _utf8NoBom.GetBytes(value, 0, value.Length, target, offset); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", + "SA1108:Block statements should not contain embedded comments", Justification = "Byte offset comments for clarity")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", + "SA1122:Use string.Empty for empty strings", Justification = "Subjective, but; ugly")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204:Static elements should appear before instance elements", Justification = "False positive?")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Encoding details; clear in context")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Borderline")] + public static ParseResult TryParse(ReadOnlySpan bytes, string key, TagSet knownTags, DefaultHybridCache cache, + out ReadOnlySpan payload, out PayloadFlags flags, out ushort entropy, out TagSet pendingTags) + { + // note "cache" is used primarily for expiration checks; we don't automatically add etc + entropy = 0; + payload = default; + flags = 0; + string[] pendingTagBuffer = []; + int pendingTagsCount = 0; + + pendingTags = TagSet.Empty; + + if (bytes.Length < 19) // minimum needed for empty payload and zero tags + { + return ParseResult.NotRecognized; + } + + var now = cache.CurrentTimestamp(); + char[] scratch = []; + try + { + switch (BinaryPrimitives.ReadUInt16LittleEndian(bytes)) + { + case UInt16SentinelPrefixPair: + entropy = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(2)); + var creationTime = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(4)); + bytes = bytes.Slice(12); // the end of the fixed part + + if (cache.IsWildcardExpired(creationTime)) + { + return ParseResult.ExpiredWildcard; + } + + if (!TryRead7BitEncodedInt64(ref bytes, out var u64)) // flags + { + return ParseResult.InvalidData; + } + + flags = (PayloadFlags)u64; + + if (!TryRead7BitEncodedInt64(ref bytes, out u64) || u64 > int.MaxValue) // payload length + { + return ParseResult.InvalidData; + } + + var payloadLength = (int)u64; + + if (!TryRead7BitEncodedInt64(ref bytes, out var duration)) // duration + { + return ParseResult.InvalidData; + } + + if ((creationTime + (long)duration) <= now) + { + return ParseResult.ExpiredSelf; + } + + if (!TryRead7BitEncodedInt64(ref bytes, out u64) || u64 > int.MaxValue) // tag count + { + return ParseResult.InvalidData; + } + + var tagCount = (int)u64; + + if (!TryReadString(ref bytes, ref scratch, out var stringSpan)) + { + return ParseResult.InvalidData; + } + + if (!stringSpan.SequenceEqual(key.AsSpan())) + { + return ParseResult.InvalidKey; // key must match! + } + + for (int i = 0; i < tagCount; i++) + { + if (!TryReadString(ref bytes, ref scratch, out stringSpan)) + { + return ParseResult.InvalidData; + } + + bool isTagExpired; + bool isPending; + if (knownTags.TryFind(stringSpan, out var tagString)) + { + // prefer to re-use existing tag strings when they exist + isTagExpired = cache.IsTagExpired(tagString, creationTime, out isPending); + } + else + { + // if an unknown tag; we might need to juggle + isTagExpired = cache.IsTagExpired(stringSpan, creationTime, out isPending); + } + + if (isPending) + { + // might be expired, but the operation is still in-flight + if (pendingTagsCount == pendingTagBuffer.Length) + { + var newBuffer = ArrayPool.Shared.Rent(Math.Max(4, pendingTagsCount * 2)); + pendingTagBuffer.CopyTo(newBuffer, 0); + ArrayPool.Shared.Return(pendingTagBuffer); + pendingTagBuffer = newBuffer; + } + + pendingTagBuffer[pendingTagsCount++] = tagString ?? stringSpan.ToString(); + } + else if (isTagExpired) + { + // definitely an expired tag + return ParseResult.ExpiredTag; + } + } + + if (bytes.Length != payloadLength + 2 + || BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(payloadLength)) != UInt16SentinelPrefixPair) + { + return ParseResult.InvalidData; + } + + payload = bytes.Slice(0, payloadLength); + + // finalize the pending tag buffer (in-flight tag expirations) + switch (pendingTagsCount) + { + case 0: + break; + case 1: + pendingTags = new(pendingTagBuffer[0]); + break; + default: + var final = new string[pendingTagsCount]; + pendingTagBuffer.CopyTo(final, 0); + pendingTags = new(final); + break; + } + + return ParseResult.Success; + default: + return ParseResult.NotRecognized; + } + } + finally + { + ArrayPool.Shared.Return(scratch); + ArrayPool.Shared.Return(pendingTagBuffer); + } + + static bool TryReadString(ref ReadOnlySpan buffer, ref char[] scratch, out ReadOnlySpan value) + { + int length; + if (!TryRead7BitEncodedInt64(ref buffer, out var u64Length) + || u64Length > int.MaxValue + || buffer.Length < (length = (int)u64Length)) // note buffer is now past the prefix via "ref" + { + value = default; + return false; + } + + // make sure we have enough buffer space + var maxChars = _utf8NoBom.GetMaxCharCount(length); + if (scratch.Length < maxChars) + { + ArrayPool.Shared.Return(scratch); + scratch = ArrayPool.Shared.Rent(maxChars); + } + + // decode +#if NETCOREAPP3_1_OR_GREATER + var charCount = _utf8NoBom.GetChars(buffer.Slice(0, length), scratch); +#else + int charCount; + unsafe + { + fixed (byte* bPtr = buffer) + { + fixed (char* cPtr = scratch) + { + charCount = _utf8NoBom.GetChars(bPtr, length, cPtr, scratch.Length); + } + } + } +#endif + value = new(scratch, 0, charCount); + buffer = buffer.Slice(length); + return true; + } + + static bool TryRead7BitEncodedInt64(ref ReadOnlySpan buffer, out ulong result) + { + byte byteReadJustNow; + + // Read the integer 7 bits at a time. The high bit + // of the byte when on means to continue reading more bytes. + // + // There are two failure cases: we've read more than 10 bytes, + // or the tenth byte is about to cause integer overflow. + // This means that we can read the first 9 bytes without + // worrying about integer overflow. + + const int MaxBytesWithoutOverflow = 9; + result = 0; + int index = 0; + for (int shift = 0; shift < MaxBytesWithoutOverflow * 7; shift += 7) + { + // ReadByte handles end of stream cases for us. + byteReadJustNow = buffer[index++]; + result |= (byteReadJustNow & 0x7Ful) << shift; + + if (byteReadJustNow <= 0x7Fu) + { + buffer = buffer.Slice(index); + return true; // early exit + } + } + + // Read the 10th byte. Since we already read 63 bits, + // the value of this byte must fit within 1 bit (64 - 63), + // and it must not have the high bit set. + + byteReadJustNow = buffer[index++]; + if (byteReadJustNow > 0b_1u) + { + throw new OverflowException(); + } + + result |= (ulong)byteReadJustNow << (MaxBytesWithoutOverflow * 7); + buffer = buffer.Slice(index); + return true; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/TagSet.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/TagSet.cs index 62df3b4ccaa..66ccbd29926 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/TagSet.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/TagSet.cs @@ -5,6 +5,7 @@ using System.Buffers; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -18,13 +19,13 @@ internal readonly struct TagSet private readonly object? _tagOrTags; - private TagSet(string tag) + internal TagSet(string tag) { Validate(tag); _tagOrTags = tag; } - private TagSet(string[] tags) + internal TagSet(string[] tags) { Debug.Assert(tags is { Length: > 1 }, "should be non-trivial array"); foreach (var tag in tags) @@ -32,6 +33,7 @@ private TagSet(string[] tags) Validate(tag); } + Array.Sort(tags, StringComparer.InvariantCulture); _tagOrTags = tags; } @@ -168,6 +170,31 @@ internal static TagSet Create(IEnumerable? tags) internal const string WildcardTag = "*"; + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1122:Use string.Empty for empty strings", Justification = "Not needed")] + internal bool TryFind(ReadOnlySpan span, [NotNullWhen(true)] out string? tag) + { + switch (_tagOrTags) + { + case string single when span.SequenceEqual(single.AsSpan()): + tag = single; + return true; + case string[] tags: + foreach (string test in tags) + { + if (span.SequenceEqual(test.AsSpan())) + { + tag = test; + return true; + } + } + + break; + } + + tag = null; + return false; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S3928:Parameter names used into ArgumentException constructors should match an existing one ", Justification = "Using parameter name from public callable API")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2208:Instantiate argument exceptions correctly", Justification = "Using parameter name from public callable API")] diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs index 4f3766990cc..b07d41fb629 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs @@ -26,9 +26,9 @@ protected DistributedCacheTests(ITestOutputHelper log) protected abstract ValueTask ConfigureAsync(IServiceCollection services); protected abstract bool CustomClockSupported { get; } - protected FakeTime Clock { get; } = new(); + internal FakeTime Clock { get; } = new(); - protected sealed class FakeTime : TimeProvider, ISystemClock + internal sealed class FakeTime : TimeProvider, ISystemClock { private DateTimeOffset _now = DateTimeOffset.UtcNow; public void Reset() => _now = DateTimeOffset.UtcNow; diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs index f6551cb7904..ab2f8becd0c 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.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.ComponentModel; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.Caching.Memory; @@ -44,6 +43,7 @@ public async Task GlobalInvalidateNoTags() // which should now be repeatable again Assert.Equal(newValue, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()))); } + static class Options { public static IOptions Create(T value) diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs new file mode 100644 index 00000000000..4fd87f5540e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs @@ -0,0 +1,259 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; +using static Microsoft.Extensions.Caching.Hybrid.Tests.DistributedCacheTests; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class PayloadTests(ITestOutputHelper log) +{ + private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) + { + var services = new ServiceCollection(); + config?.Invoke(services); + services.AddHybridCache(); + ServiceProvider provider = services.BuildServiceProvider(); + cache = Assert.IsType(provider.GetRequiredService()); + return provider; + } + + [Fact] + public void RoundTrip_Success() + { + var clock = new FakeTime(); + using var provider = GetDefaultCache(out var cache, config => + { + config.AddSingleton(clock); + }); + + byte[] bytes = new byte[1024]; + new Random().NextBytes(bytes); + + string key = "my key"; + var tags = TagSet.Create(["some_tag"]); + var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var oversized = ArrayPool.Shared.Rent(maxLen); + + int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + log.WriteLine($"bytes written: {actualLength}"); + Assert.Equal(1063, actualLength); + + clock.Add(TimeSpan.FromSeconds(10)); + var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + log.WriteLine($"Entropy: {entropy}; Flags: {flags}"); + Assert.Equal(DistributedCachePayload.ParseResult.Success, result); + Assert.True(payload.SequenceEqual(bytes)); + Assert.True(pendingTags.IsEmpty); + } + + [Fact] + public void RoundTrip_SelfExpiration() + { + var clock = new FakeTime(); + using var provider = GetDefaultCache(out var cache, config => + { + config.AddSingleton(clock); + }); + + byte[] bytes = new byte[1024]; + new Random().NextBytes(bytes); + + string key = "my key"; + var tags = TagSet.Create(["some_tag"]); + var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var oversized = ArrayPool.Shared.Rent(maxLen); + + int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + log.WriteLine($"bytes written: {actualLength}"); + Assert.Equal(1063, actualLength); + + clock.Add(TimeSpan.FromSeconds(58)); + var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(DistributedCachePayload.ParseResult.Success, result); + Assert.True(payload.SequenceEqual(bytes)); + Assert.True(pendingTags.IsEmpty); + + clock.Add(TimeSpan.FromSeconds(4)); + result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out payload, out flags, out entropy, out pendingTags); + Assert.Equal(DistributedCachePayload.ParseResult.ExpiredSelf, result); + Assert.Equal(0, payload.Length); + Assert.True(pendingTags.IsEmpty); + } + + [Fact] + public async Task RoundTrip_WildcardExpiration() + { + var clock = new FakeTime(); + using var provider = GetDefaultCache(out var cache, config => + { + config.AddSingleton(clock); + }); + + byte[] bytes = new byte[1024]; + new Random().NextBytes(bytes); + + string key = "my key"; + var tags = TagSet.Create(["some_tag"]); + var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var oversized = ArrayPool.Shared.Rent(maxLen); + + int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + log.WriteLine($"bytes written: {actualLength}"); + Assert.Equal(1063, actualLength); + + clock.Add(TimeSpan.FromSeconds(2)); + await cache.RemoveByTagAsync("*"); + + var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(DistributedCachePayload.ParseResult.ExpiredWildcard, result); + Assert.Equal(0, payload.Length); + Assert.True(pendingTags.IsEmpty); + } + + [Fact] + public async Task RoundTrip_TagExpiration() + { + var clock = new FakeTime(); + using var provider = GetDefaultCache(out var cache, config => + { + config.AddSingleton(clock); + }); + + byte[] bytes = new byte[1024]; + new Random().NextBytes(bytes); + + string key = "my key"; + var tags = TagSet.Create(["some_tag"]); + var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var oversized = ArrayPool.Shared.Rent(maxLen); + + int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + log.WriteLine($"bytes written: {actualLength}"); + Assert.Equal(1063, actualLength); + + clock.Add(TimeSpan.FromSeconds(2)); + await cache.RemoveByTagAsync("other_tag"); + + var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(DistributedCachePayload.ParseResult.Success, result); + Assert.True(payload.SequenceEqual(bytes)); + Assert.True(pendingTags.IsEmpty); + + await cache.RemoveByTagAsync("some_tag"); + result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out payload, out flags, out entropy, out pendingTags); + Assert.Equal(DistributedCachePayload.ParseResult.ExpiredTag, result); + Assert.Equal(0, payload.Length); + Assert.True(pendingTags.IsEmpty); + } + + [Fact] + public async Task RoundTrip_TagExpiration_Pending() + { + var clock = new FakeTime(); + using var provider = GetDefaultCache(out var cache, config => + { + config.AddSingleton(clock); + }); + + byte[] bytes = new byte[1024]; + new Random().NextBytes(bytes); + + string key = "my key"; + var tags = TagSet.Create(["some_tag"]); + var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var oversized = ArrayPool.Shared.Rent(maxLen); + + var creation = cache.CurrentTimestamp(); + int actualLength = DistributedCachePayload.Write(oversized, key, creation, TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + log.WriteLine($"bytes written: {actualLength}"); + Assert.Equal(1063, actualLength); + + clock.Add(TimeSpan.FromSeconds(2)); + + var tcs = new TaskCompletionSource(); + cache.DebugInvalidateTag("some_tag", tcs.Task); + var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(DistributedCachePayload.ParseResult.Success, result); + Assert.True(payload.SequenceEqual(bytes)); + Assert.Equal(1, pendingTags.Count); + Assert.Equal("some_tag", pendingTags[0]); + + tcs.SetResult(cache.CurrentTimestamp()); + Assert.True(await cache.IsAnyTagExpiredAsync(pendingTags, creation)); + } + + [Fact] + public void Gibberish() + { + var clock = new FakeTime(); + using var provider = GetDefaultCache(out var cache, config => + { + config.AddSingleton(clock); + }); + + byte[] bytes = new byte[1024]; + new Random().NextBytes(bytes); + + var result = DistributedCachePayload.TryParse(bytes, "whatever", TagSet.Empty, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(DistributedCachePayload.ParseResult.NotRecognized, result); + Assert.Equal(0, payload.Length); + Assert.True(pendingTags.IsEmpty); + } + + [Fact] + public void RoundTrip_Truncated() + { + var clock = new FakeTime(); + using var provider = GetDefaultCache(out var cache, config => + { + config.AddSingleton(clock); + }); + + byte[] bytes = new byte[1024]; + new Random().NextBytes(bytes); + + string key = "my key"; + var tags = TagSet.Create(["some_tag"]); + var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var oversized = ArrayPool.Shared.Rent(maxLen); + + int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + log.WriteLine($"bytes written: {actualLength}"); + Assert.Equal(1063, actualLength); + + var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength - 1), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(DistributedCachePayload.ParseResult.InvalidData, result); + Assert.Equal(0, payload.Length); + Assert.True(pendingTags.IsEmpty); + } + + [Fact] + public void RoundTrip_Oversized() + { + var clock = new FakeTime(); + using var provider = GetDefaultCache(out var cache, config => + { + config.AddSingleton(clock); + }); + + byte[] bytes = new byte[1024]; + new Random().NextBytes(bytes); + + string key = "my key"; + var tags = TagSet.Create(["some_tag"]); + var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length) + 1; + var oversized = ArrayPool.Shared.Rent(maxLen); + + int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + log.WriteLine($"bytes written: {actualLength}"); + Assert.Equal(1063, actualLength); + + var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength + 1), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(DistributedCachePayload.ParseResult.InvalidData, result); + Assert.Equal(0, payload.Length); + Assert.True(pendingTags.IsEmpty); + } +} From 2faa3f758eb97821cdd8ed360313e3929b0b8466 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 17 Dec 2024 16:58:07 +0000 Subject: [PATCH 189/190] plug the L2 payload bits into the pipe --- .../Internal/BufferChunk.cs | 26 +++--- .../Internal/DefaultHybridCache.L2.cs | 38 ++++++--- .../DefaultHybridCache.StampedeStateT.cs | 32 ++++++-- .../DefaultHybridCache.TagInvalidation.cs | 2 +- .../Internal/DefaultHybridCache.cs | 1 - ...dCachePayload.cs => HybridCachePayload.cs} | 11 +-- .../Internal/RecyclableArrayBufferWriter.cs | 2 + .../BufferReleaseTests.cs | 12 ++- .../L2Tests.cs | 24 +++--- .../LocalInvalidationTests.cs | 2 +- .../PayloadTests.cs | 80 +++++++++---------- 11 files changed, 139 insertions(+), 91 deletions(-) rename src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/{DistributedCachePayload.cs => HybridCachePayload.cs} (97%) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/BufferChunk.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/BufferChunk.cs index c4a7a4327cb..d17eacb3484 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/BufferChunk.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/BufferChunk.cs @@ -15,11 +15,13 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; internal readonly struct BufferChunk { private const int FlagReturnToPool = (1 << 31); - private readonly int _lengthAndPoolFlag; - public byte[]? Array { get; } // null for default + public byte[]? OversizedArray { get; } // null for default + + public bool HasValue => OversizedArray is not null; + public int Offset { get; } public int Length => _lengthAndPoolFlag & ~FlagReturnToPool; public bool ReturnToPool => (_lengthAndPoolFlag & FlagReturnToPool) != 0; @@ -27,8 +29,9 @@ internal readonly struct BufferChunk public BufferChunk(byte[] array) { Debug.Assert(array is not null, "expected valid array input"); - Array = array; + OversizedArray = array; _lengthAndPoolFlag = array!.Length; + Offset = 0; // assume not pooled, if exact-sized // (we don't expect array.Length to be negative; we're really just saying @@ -39,11 +42,12 @@ public BufferChunk(byte[] array) Debug.Assert(Length == array.Length, "array length not respected"); } - public BufferChunk(byte[] array, int length, bool returnToPool) + public BufferChunk(byte[] array, int offset, int length, bool returnToPool) { Debug.Assert(array is not null, "expected valid array input"); Debug.Assert(length >= 0, "expected valid length"); - Array = array; + OversizedArray = array; + Offset = offset; _lengthAndPoolFlag = length | (returnToPool ? FlagReturnToPool : 0); Debug.Assert(ReturnToPool == returnToPool, "return-to-pool not respected"); Debug.Assert(Length == length, "length not respected"); @@ -58,7 +62,7 @@ public byte[] ToArray() } var copy = new byte[length]; - Buffer.BlockCopy(Array!, 0, copy, 0, length); + Buffer.BlockCopy(OversizedArray!, Offset, copy, 0, length); return copy; // Note on nullability of Array; the usage here is that a non-null array @@ -73,17 +77,19 @@ internal void RecycleIfAppropriate() { if (ReturnToPool) { - ArrayPool.Shared.Return(Array!); + ArrayPool.Shared.Return(OversizedArray!); } Unsafe.AsRef(in this) = default; // anti foot-shotgun double-return guard; not 100%, but worth doing - Debug.Assert(Array is null && !ReturnToPool, "expected clean slate after recycle"); + Debug.Assert(OversizedArray is null && !ReturnToPool, "expected clean slate after recycle"); } - internal ReadOnlySpan AsSpan() => Length == 0 ? default : new(Array!, 0, Length); + internal ArraySegment AsArraySegment() => Length == 0 ? default! : new(OversizedArray!, Offset, Length); + + internal ReadOnlySpan AsSpan() => Length == 0 ? default : new(OversizedArray!, Offset, Length); // get the data as a ROS; for note on null-logic of Array!, see comment in ToArray - internal ReadOnlySequence AsSequence() => Length == 0 ? default : new ReadOnlySequence(Array!, 0, Length); + internal ReadOnlySequence AsSequence() => Length == 0 ? default : new ReadOnlySequence(OversizedArray!, Offset, Length); internal BufferChunk DoNotReturnToPool() { diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs index a57198f3887..7d28127fe19 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs @@ -20,11 +20,13 @@ internal partial class DefaultHybridCache private const string TagKeyPrefix = "__MSFT_HCT__"; private static readonly DistributedCacheEntryOptions _tagInvalidationEntryOptions = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(MaxCacheDays) }; + private static readonly TimeSpan _defaultTimeout = TimeSpan.FromHours(1); + [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Manual sync check")] [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Manual sync check")] [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Explicit async exception handling")] [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Deliberate recycle only on success")] - internal ValueTask GetFromL2Async(string key, CancellationToken token) + internal ValueTask GetFromL2DirectAsync(string key, CancellationToken token) { switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) { @@ -54,7 +56,7 @@ internal ValueTask GetFromL2Async(string key, CancellationToken tok } BufferChunk result = pendingBuffers.GetAwaiter().GetResult() - ? new(writer.DetachCommitted(out var length), length, returnToPool: true) + ? new(writer.DetachCommitted(out var length), 0, length, returnToPool: true) : default; writer.Dispose(); // it is not accidental that this isn't "using"; avoid recycling if not 100% sure what happened return new(result); @@ -71,24 +73,24 @@ static async Task AwaitedLegacyAsync(Task pending, Default static async Task AwaitedBuffersAsync(ValueTask pending, RecyclableArrayBufferWriter writer) { BufferChunk result = await pending.ConfigureAwait(false) - ? new(writer.DetachCommitted(out var length), length, returnToPool: true) + ? new(writer.DetachCommitted(out var length), 0, length, returnToPool: true) : default; writer.Dispose(); // it is not accidental that this isn't "using"; avoid recycling if not 100% sure what happened return result; } } - internal ValueTask SetL2Async(string key, in BufferChunk buffer, HybridCacheEntryOptions? options, CancellationToken token) - => HasBackendCache ? SetDirectL2Async(key, in buffer, GetOptions(options), token) : default; + internal ValueTask SetL2Async(string key, CacheItem cacheItem, in BufferChunk buffer, HybridCacheEntryOptions? options, CancellationToken token) + => HasBackendCache ? WritePayloadAsync(key, cacheItem, buffer, options, token) : default; internal ValueTask SetDirectL2Async(string key, in BufferChunk buffer, DistributedCacheEntryOptions options, CancellationToken token) { - Debug.Assert(buffer.Array is not null, "array should be non-null"); + Debug.Assert(buffer.OversizedArray is not null, "array should be non-null"); switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) { case CacheFeatures.BackendCache: // legacy byte[]-based - var arr = buffer.Array!; - if (arr.Length != buffer.Length) + var arr = buffer.OversizedArray!; + if (buffer.Offset != 0 || arr.Length != buffer.Length) { // we'll need a right-sized snapshot arr = buffer.ToArray(); @@ -113,7 +115,7 @@ internal ValueTask InvalidateL2TagAsync(string tag, long timestamp, Cancellation byte[] oversized = ArrayPool.Shared.Rent(sizeof(long)); BinaryPrimitives.WriteInt64LittleEndian(oversized, timestamp); - var pending = SetDirectL2Async(TagKeyPrefix + tag, new BufferChunk(oversized, sizeof(long), false), _tagInvalidationEntryOptions, token); + var pending = SetDirectL2Async(TagKeyPrefix + tag, new BufferChunk(oversized, 0, sizeof(long), false), _tagInvalidationEntryOptions, token); if (pending.IsCompletedSuccessfully) { @@ -144,10 +146,10 @@ internal async Task SafeReadTagInvalidationAsync(string tag) try { using var cts = new CancellationTokenSource(millisecondsDelay: READ_TIMEOUT); - var buffer = await GetFromL2Async(TagKeyPrefix + tag, cts.Token).ConfigureAwait(false); + var buffer = await GetFromL2DirectAsync(TagKeyPrefix + tag, cts.Token).ConfigureAwait(false); long timestamp; - if (buffer.Array is not null) + if (buffer.OversizedArray is not null) { if (buffer.Length == sizeof(long)) { @@ -212,6 +214,20 @@ internal void SetL1(string key, CacheItem value, HybridCacheEntryOptions? } } + private async ValueTask WritePayloadAsync(string key, CacheItem cacheItem, BufferChunk payload, HybridCacheEntryOptions? options, CancellationToken token) + { + // bundle a serialized payload inside the wrapper used at the DC layer + var maxLength = HybridCachePayload.GetMaxBytes(key, cacheItem.Tags, payload.Length); + var oversized = ArrayPool.Shared.Rent(maxLength); + + var length = HybridCachePayload.Write(oversized, key, cacheItem.CreationTimestamp, options?.Expiration ?? _defaultTimeout, + HybridCachePayload.PayloadFlags.None, cacheItem.Tags, payload.AsSequence()); + + await SetDirectL2Async(key, new(oversized, 0, length, true), GetOptions(options), token).ConfigureAwait(false); + + ArrayPool.Shared.Return(oversized); + } + private BufferChunk GetValidPayloadSegment(byte[]? payload) { if (payload is not null) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs index ef4cdaaf8ed..34bca58287a 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs @@ -180,10 +180,10 @@ private async Task BackgroundFetchAsync() HybridCacheEventSource.Log.DistributedCacheGet(); } - result = await Cache.GetFromL2Async(Key.Key, SharedToken).ConfigureAwait(false); + result = await Cache.GetFromL2DirectAsync(Key.Key, SharedToken).ConfigureAwait(false); if (eventSourceEnabled) { - if (result.Array is not null) + if (result.HasValue) { HybridCacheEventSource.Log.DistributedCacheHit(); } @@ -213,10 +213,26 @@ private async Task BackgroundFetchAsync() result = default; // treat as "miss" } - if (result.Array is not null) + if (result.HasValue) { - SetResultAndRecycleIfAppropriate(ref result); - return; + // result is the wider payload including HC headers; unwrap it: + switch (HybridCachePayload.TryParse(result.AsArraySegment(), Key.Key, CacheItem.Tags, Cache, out var payload, + out var flags, out var entropy, out var pendingTags)) + { + case HybridCachePayload.ParseResult.Success: + // check any pending expirations, if necessary + if (pendingTags.IsEmpty || !await Cache.IsAnyTagExpiredAsync(pendingTags, CacheItem.CreationTimestamp).ConfigureAwait(false)) + { + // move into the payload segment (minus any framing/header/etc data) + result = new(payload.Array!, payload.Offset, payload.Count, result.ReturnToPool); + SetResultAndRecycleIfAppropriate(ref result); + return; + } + + break; + } + + result.RecycleIfAppropriate(); } } @@ -304,7 +320,7 @@ private async Task BackgroundFetchAsync() // We already have the payload serialized, so this is trivial to do. try { - await Cache.SetL2Async(Key.Key, in buffer, _options, SharedToken).ConfigureAwait(false); + await Cache.SetL2Async(Key.Key, cacheItem, in buffer, _options, SharedToken).ConfigureAwait(false); if (eventSourceEnabled) { @@ -377,7 +393,7 @@ private void SetDefaultResult() private void SetResultAndRecycleIfAppropriate(ref BufferChunk value) { // set a result from L2 cache - Debug.Assert(value.Array is not null, "expected buffer"); + Debug.Assert(value.OversizedArray is not null, "expected buffer"); IHybridCacheSerializer serializer = Cache.GetSerializer(); CacheItem cacheItem; @@ -385,7 +401,7 @@ private void SetResultAndRecycleIfAppropriate(ref BufferChunk value) { case ImmutableCacheItem immutable: // deserialize; and store object; buffer can be recycled now - immutable.SetValue(serializer.Deserialize(new(value.Array!, 0, value.Length)), value.Length); + immutable.SetValue(serializer.Deserialize(new(value.OversizedArray!, value.Offset, value.Length)), value.Length); value.RecycleIfAppropriate(); cacheItem = immutable; break; diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs index 326263efa70..1c46dafe352 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs @@ -137,7 +137,6 @@ public bool IsTagExpired(string tag, long timestamp, out bool isPending) } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "Ack")] public ValueTask IsAnyTagExpiredAsync(TagSet tags, long timestamp) { @@ -165,6 +164,7 @@ static async ValueTask SlowAsync(DefaultHybridCache @this, TagSet tags, lo [System.Diagnostics.CodeAnalysis.SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "Ack")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Completion-checked")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Manual async unwrap")] public ValueTask IsTagExpiredAsync(string tag, long timestamp) { if (!_tagInvalidationTimes.TryGetValue(tag, out var pending)) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs index 6eb026125a5..9ee647cf07d 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DistributedCachePayload.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs similarity index 97% rename from src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DistributedCachePayload.cs rename to src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs index 5bc2979f5cc..50edf21dff9 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DistributedCachePayload.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; // logic related to the payload that we send to IDistributedCache -internal static class DistributedCachePayload +internal static class HybridCachePayload { // FORMAT (v1): // fixed-size header (so that it can be reliably broadcast) @@ -172,8 +172,8 @@ static void WriteString(byte[] target, ref int offset, string value) [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204:Static elements should appear before instance elements", Justification = "False positive?")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Encoding details; clear in context")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Borderline")] - public static ParseResult TryParse(ReadOnlySpan bytes, string key, TagSet knownTags, DefaultHybridCache cache, - out ReadOnlySpan payload, out PayloadFlags flags, out ushort entropy, out TagSet pendingTags) + public static ParseResult TryParse(ArraySegment source, string key, TagSet knownTags, DefaultHybridCache cache, + out ArraySegment payload, out PayloadFlags flags, out ushort entropy, out TagSet pendingTags) { // note "cache" is used primarily for expiration checks; we don't automatically add etc entropy = 0; @@ -183,7 +183,7 @@ public static ParseResult TryParse(ReadOnlySpan bytes, string key, TagSet int pendingTagsCount = 0; pendingTags = TagSet.Empty; - + ReadOnlySpan bytes = new(source.Array!, source.Offset, source.Count); if (bytes.Length < 19) // minimum needed for empty payload and zero tags { return ParseResult.NotRecognized; @@ -292,7 +292,8 @@ public static ParseResult TryParse(ReadOnlySpan bytes, string key, TagSet return ParseResult.InvalidData; } - payload = bytes.Slice(0, payloadLength); + var start = source.Offset + source.Count - (payloadLength + 2); + payload = new(source.Array!, start, payloadLength); // finalize the pending tag buffer (in-flight tag expirations) switch (pendingTagsCount) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs index 985d55c9f0e..82d7fba4755 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/RecyclableArrayBufferWriter.cs @@ -131,6 +131,8 @@ public Span GetSpan(int sizeHint = 0) // create a standalone isolated copy of the buffer public T[] ToArray() => _buffer.AsSpan(0, _index).ToArray(); + public ReadOnlySequence AsSequence() => new(_buffer, 0, _index); + /// /// Disconnect the current buffer so that we can store it without it being recycled. /// diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs index 4996406c09a..21b901c9482 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs @@ -121,7 +121,11 @@ private static bool Write(IBufferWriter destination, byte[]? buffer) using (RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(int.MaxValue)) { serializer.Serialize(await GetAsync(), writer); - cache.BackendCache.Set(key, writer.ToArray()); + + var arr = ArrayPool.Shared.Rent(HybridCachePayload.GetMaxBytes(key, TagSet.Empty, writer.CommittedBytes)); + var bytes = HybridCachePayload.Write(arr, key, cache.CurrentTimestamp(), TimeSpan.FromHours(1), 0, TagSet.Empty, writer.AsSequence()); + cache.BackendCache.Set(key, new ReadOnlySpan(arr, 0, bytes).ToArray()); + ArrayPool.Shared.Return(arr); } #if DEBUG cache.DebugOnlyGetOutstandingBuffers(flush: true); @@ -180,7 +184,11 @@ private static bool Write(IBufferWriter destination, byte[]? buffer) using (RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(int.MaxValue)) { serializer.Serialize(await GetAsync(), writer); - cache.BackendCache.Set(key, writer.ToArray()); + + var arr = ArrayPool.Shared.Rent(HybridCachePayload.GetMaxBytes(key, TagSet.Empty, writer.CommittedBytes)); + var bytes = HybridCachePayload.Write(arr, key, cache.CurrentTimestamp(), TimeSpan.FromHours(1), 0, TagSet.Empty, writer.AsSequence()); + cache.BackendCache.Set(key, new ReadOnlySpan(arr, 0, bytes).ToArray()); + ArrayPool.Shared.Return(arr); } #if DEBUG cache.DebugOnlyGetOutstandingBuffers(flush: true); diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs index de93385f738..948df9d8814 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs @@ -52,7 +52,7 @@ public async Task AssertL2Operations_Immutable(bool buffers) var backend = Assert.IsAssignableFrom(cache.BackendCache); Log.WriteLine("Inventing key..."); var s = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(CreateString(true))); - Assert.Equal(2, backend.OpCount); // GET, SET + Assert.Equal(3, backend.OpCount); // (wildcard timstamp GET), GET, SET Log.WriteLine("Reading with L1..."); for (var i = 0; i < 5; i++) @@ -62,7 +62,7 @@ public async Task AssertL2Operations_Immutable(bool buffers) Assert.Same(s, x); } - Assert.Equal(2, backend.OpCount); // shouldn't be hit + Assert.Equal(3, backend.OpCount); // shouldn't be hit Log.WriteLine("Reading without L1..."); for (var i = 0; i < 5; i++) @@ -72,7 +72,7 @@ public async Task AssertL2Operations_Immutable(bool buffers) Assert.NotSame(s, x); } - Assert.Equal(7, backend.OpCount); // should be read every time + Assert.Equal(8, backend.OpCount); // should be read every time Log.WriteLine("Setting value directly"); s = CreateString(true); @@ -84,16 +84,16 @@ public async Task AssertL2Operations_Immutable(bool buffers) Assert.Same(s, x); } - Assert.Equal(8, backend.OpCount); // SET + Assert.Equal(9, backend.OpCount); // SET Log.WriteLine("Removing key..."); await cache.RemoveAsync(Me()); - Assert.Equal(9, backend.OpCount); // DEL + Assert.Equal(10, backend.OpCount); // DEL Log.WriteLine("Fetching new..."); var t = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(CreateString(true))); Assert.NotEqual(s, t); - Assert.Equal(11, backend.OpCount); // GET, SET + Assert.Equal(12, backend.OpCount); // GET, SET } public sealed class Foo @@ -110,7 +110,7 @@ public async Task AssertL2Operations_Mutable(bool buffers) var backend = Assert.IsAssignableFrom(cache.BackendCache); Log.WriteLine("Inventing key..."); var s = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString(true) }), _expiry); - Assert.Equal(2, backend.OpCount); // GET, SET + Assert.Equal(3, backend.OpCount); // (wildcard timstamp GET), GET, SET Log.WriteLine("Reading with L1..."); for (var i = 0; i < 5; i++) @@ -120,7 +120,7 @@ public async Task AssertL2Operations_Mutable(bool buffers) Assert.NotSame(s, x); } - Assert.Equal(2, backend.OpCount); // shouldn't be hit + Assert.Equal(3, backend.OpCount); // shouldn't be hit Log.WriteLine("Reading without L1..."); for (var i = 0; i < 5; i++) @@ -130,7 +130,7 @@ public async Task AssertL2Operations_Mutable(bool buffers) Assert.NotSame(s, x); } - Assert.Equal(7, backend.OpCount); // should be read every time + Assert.Equal(8, backend.OpCount); // should be read every time Log.WriteLine("Setting value directly"); s = new Foo { Value = CreateString(true) }; @@ -142,16 +142,16 @@ public async Task AssertL2Operations_Mutable(bool buffers) Assert.NotSame(s, x); } - Assert.Equal(8, backend.OpCount); // SET + Assert.Equal(9, backend.OpCount); // SET Log.WriteLine("Removing key..."); await cache.RemoveAsync(Me()); - Assert.Equal(9, backend.OpCount); // DEL + Assert.Equal(10, backend.OpCount); // DEL Log.WriteLine("Fetching new..."); var t = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString(true) }), _expiry); Assert.NotEqual(s.Value, t.Value); - Assert.Equal(11, backend.OpCount); // GET, SET + Assert.Equal(12, backend.OpCount); // GET, SET } private class BufferLoggingCache : LoggingCache, IBufferDistributedCache diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs index ab2f8becd0c..c2fc1da7c84 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs @@ -44,7 +44,7 @@ public async Task GlobalInvalidateNoTags() Assert.Equal(newValue, await cache.GetOrCreateAsync("abc", ct => new(Guid.NewGuid()))); } - static class Options + private static class Options { public static IOptions Create(T value) where T : class diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs index 4fd87f5540e..5ddaa562f5d 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs @@ -34,17 +34,17 @@ public void RoundTrip_Success() string key = "my key"; var tags = TagSet.Create(["some_tag"]); - var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var maxLen = HybridCachePayload.GetMaxBytes(key, tags, bytes.Length); var oversized = ArrayPool.Shared.Rent(maxLen); - int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + int actualLength = HybridCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); log.WriteLine($"bytes written: {actualLength}"); Assert.Equal(1063, actualLength); clock.Add(TimeSpan.FromSeconds(10)); - var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + var result = HybridCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); log.WriteLine($"Entropy: {entropy}; Flags: {flags}"); - Assert.Equal(DistributedCachePayload.ParseResult.Success, result); + Assert.Equal(HybridCachePayload.ParseResult.Success, result); Assert.True(payload.SequenceEqual(bytes)); Assert.True(pendingTags.IsEmpty); } @@ -63,23 +63,23 @@ public void RoundTrip_SelfExpiration() string key = "my key"; var tags = TagSet.Create(["some_tag"]); - var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var maxLen = HybridCachePayload.GetMaxBytes(key, tags, bytes.Length); var oversized = ArrayPool.Shared.Rent(maxLen); - int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + int actualLength = HybridCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); log.WriteLine($"bytes written: {actualLength}"); Assert.Equal(1063, actualLength); clock.Add(TimeSpan.FromSeconds(58)); - var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); - Assert.Equal(DistributedCachePayload.ParseResult.Success, result); + var result = HybridCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(HybridCachePayload.ParseResult.Success, result); Assert.True(payload.SequenceEqual(bytes)); Assert.True(pendingTags.IsEmpty); clock.Add(TimeSpan.FromSeconds(4)); - result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out payload, out flags, out entropy, out pendingTags); - Assert.Equal(DistributedCachePayload.ParseResult.ExpiredSelf, result); - Assert.Equal(0, payload.Length); + result = HybridCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out payload, out flags, out entropy, out pendingTags); + Assert.Equal(HybridCachePayload.ParseResult.ExpiredSelf, result); + Assert.Equal(0, payload.Count); Assert.True(pendingTags.IsEmpty); } @@ -97,19 +97,19 @@ public async Task RoundTrip_WildcardExpiration() string key = "my key"; var tags = TagSet.Create(["some_tag"]); - var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var maxLen = HybridCachePayload.GetMaxBytes(key, tags, bytes.Length); var oversized = ArrayPool.Shared.Rent(maxLen); - int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + int actualLength = HybridCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); log.WriteLine($"bytes written: {actualLength}"); Assert.Equal(1063, actualLength); clock.Add(TimeSpan.FromSeconds(2)); await cache.RemoveByTagAsync("*"); - var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); - Assert.Equal(DistributedCachePayload.ParseResult.ExpiredWildcard, result); - Assert.Equal(0, payload.Length); + var result = HybridCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(HybridCachePayload.ParseResult.ExpiredWildcard, result); + Assert.Equal(0, payload.Count); Assert.True(pendingTags.IsEmpty); } @@ -127,25 +127,25 @@ public async Task RoundTrip_TagExpiration() string key = "my key"; var tags = TagSet.Create(["some_tag"]); - var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var maxLen = HybridCachePayload.GetMaxBytes(key, tags, bytes.Length); var oversized = ArrayPool.Shared.Rent(maxLen); - int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + int actualLength = HybridCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); log.WriteLine($"bytes written: {actualLength}"); Assert.Equal(1063, actualLength); clock.Add(TimeSpan.FromSeconds(2)); await cache.RemoveByTagAsync("other_tag"); - var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); - Assert.Equal(DistributedCachePayload.ParseResult.Success, result); + var result = HybridCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(HybridCachePayload.ParseResult.Success, result); Assert.True(payload.SequenceEqual(bytes)); Assert.True(pendingTags.IsEmpty); await cache.RemoveByTagAsync("some_tag"); - result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out payload, out flags, out entropy, out pendingTags); - Assert.Equal(DistributedCachePayload.ParseResult.ExpiredTag, result); - Assert.Equal(0, payload.Length); + result = HybridCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out payload, out flags, out entropy, out pendingTags); + Assert.Equal(HybridCachePayload.ParseResult.ExpiredTag, result); + Assert.Equal(0, payload.Count); Assert.True(pendingTags.IsEmpty); } @@ -163,11 +163,11 @@ public async Task RoundTrip_TagExpiration_Pending() string key = "my key"; var tags = TagSet.Create(["some_tag"]); - var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var maxLen = HybridCachePayload.GetMaxBytes(key, tags, bytes.Length); var oversized = ArrayPool.Shared.Rent(maxLen); var creation = cache.CurrentTimestamp(); - int actualLength = DistributedCachePayload.Write(oversized, key, creation, TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + int actualLength = HybridCachePayload.Write(oversized, key, creation, TimeSpan.FromMinutes(1), 0, tags, new(bytes)); log.WriteLine($"bytes written: {actualLength}"); Assert.Equal(1063, actualLength); @@ -175,8 +175,8 @@ public async Task RoundTrip_TagExpiration_Pending() var tcs = new TaskCompletionSource(); cache.DebugInvalidateTag("some_tag", tcs.Task); - var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); - Assert.Equal(DistributedCachePayload.ParseResult.Success, result); + var result = HybridCachePayload.TryParse(new(oversized, 0, actualLength), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(HybridCachePayload.ParseResult.Success, result); Assert.True(payload.SequenceEqual(bytes)); Assert.Equal(1, pendingTags.Count); Assert.Equal("some_tag", pendingTags[0]); @@ -197,9 +197,9 @@ public void Gibberish() byte[] bytes = new byte[1024]; new Random().NextBytes(bytes); - var result = DistributedCachePayload.TryParse(bytes, "whatever", TagSet.Empty, cache, out var payload, out var flags, out var entropy, out var pendingTags); - Assert.Equal(DistributedCachePayload.ParseResult.NotRecognized, result); - Assert.Equal(0, payload.Length); + var result = HybridCachePayload.TryParse(new(bytes), "whatever", TagSet.Empty, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(HybridCachePayload.ParseResult.NotRecognized, result); + Assert.Equal(0, payload.Count); Assert.True(pendingTags.IsEmpty); } @@ -217,16 +217,16 @@ public void RoundTrip_Truncated() string key = "my key"; var tags = TagSet.Create(["some_tag"]); - var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length); + var maxLen = HybridCachePayload.GetMaxBytes(key, tags, bytes.Length); var oversized = ArrayPool.Shared.Rent(maxLen); - int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + int actualLength = HybridCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); log.WriteLine($"bytes written: {actualLength}"); Assert.Equal(1063, actualLength); - var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength - 1), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); - Assert.Equal(DistributedCachePayload.ParseResult.InvalidData, result); - Assert.Equal(0, payload.Length); + var result = HybridCachePayload.TryParse(new(oversized, 0, actualLength - 1), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(HybridCachePayload.ParseResult.InvalidData, result); + Assert.Equal(0, payload.Count); Assert.True(pendingTags.IsEmpty); } @@ -244,16 +244,16 @@ public void RoundTrip_Oversized() string key = "my key"; var tags = TagSet.Create(["some_tag"]); - var maxLen = DistributedCachePayload.GetMaxBytes(key, tags, bytes.Length) + 1; + var maxLen = HybridCachePayload.GetMaxBytes(key, tags, bytes.Length) + 1; var oversized = ArrayPool.Shared.Rent(maxLen); - int actualLength = DistributedCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); + int actualLength = HybridCachePayload.Write(oversized, key, cache.CurrentTimestamp(), TimeSpan.FromMinutes(1), 0, tags, new(bytes)); log.WriteLine($"bytes written: {actualLength}"); Assert.Equal(1063, actualLength); - var result = DistributedCachePayload.TryParse(new(oversized, 0, actualLength + 1), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); - Assert.Equal(DistributedCachePayload.ParseResult.InvalidData, result); - Assert.Equal(0, payload.Length); + var result = HybridCachePayload.TryParse(new(oversized, 0, actualLength + 1), key, tags, cache, out var payload, out var flags, out var entropy, out var pendingTags); + Assert.Equal(HybridCachePayload.ParseResult.InvalidData, result); + Assert.Equal(0, payload.Count); Assert.True(pendingTags.IsEmpty); } } From 7ffc6166539412233f0676fd9f1a90b5aa39c22e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 7 Jan 2025 14:06:04 +0000 Subject: [PATCH 190/190] deal with rebase conflicts --- .../Internal/DefaultHybridCache.L2.cs | 3 ++- .../Internal/DefaultHybridCache.Serialization.cs | 2 +- .../LocalInvalidationTests.cs | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs index 7d28127fe19..c5182035330 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs @@ -171,8 +171,9 @@ internal async Task SafeReadTagInvalidationAsync(string tag) buffer.RecycleIfAppropriate(); return timestamp; } - catch (Exception ex) // this is the "Safe" in "SafeReadTagInvalidationAsync" + catch (Exception ex) { + // ^^^ this catch is the "Safe" in "SafeReadTagInvalidationAsync" Debug.WriteLine(ex.Message); // if anything goes wrong reading tag invalidations; we have to assume the tag is invalid diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs index d12b2cce592..cb39696d532 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs @@ -67,7 +67,7 @@ private bool TrySerialize(T value, out BufferChunk buffer, out IHybridCacheSe serializer.Serialize(value, writer); - buffer = new(writer.DetachCommitted(out var length), length, returnToPool: true); // remove buffer ownership from the writer + buffer = new(writer.DetachCommitted(out var length), 0, length, returnToPool: true); // remove buffer ownership from the writer writer.Dispose(); // we're done with the writer return true; } diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs index c2fc1da7c84..1852c0d07f9 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs @@ -77,7 +77,9 @@ public async Task TagBasedInvalidate(bool withL2) } Guid lastValue = Guid.Empty; - for (int i = 0; i < 3; i++) // because we want to test pre-existing L1/L2 impact + + // loop because we want to test pre-existing L1/L2 impact + for (int i = 0; i < 3; i++) { using var services = GetDefaultCache(out var cache, svc => {